Kaynağa Gözat

merge: sync with main (latest upstream) while preserving Gemini File API customizations

supeng 1 hafta önce
ebeveyn
işleme
cf0d8902fd
100 değiştirilmiş dosya ile 11540 ekleme ve 748 silme
  1. 83 0
      .agents/skills/classic-to-default-sync/SKILL.md
  2. 254 0
      .agents/skills/i18n-translate/SKILL.md
  3. 105 0
      .agents/skills/shadcn-ui/SKILL.md
  4. 260 0
      .agents/skills/shadcn-ui/vendor/shadcn/SKILL.md
  5. 3 0
      .agents/skills/shadcn-ui/vendor/shadcn/UPSTREAM.txt
  6. 276 0
      .agents/skills/shadcn-ui/vendor/shadcn/cli.md
  7. 209 0
      .agents/skills/shadcn-ui/vendor/shadcn/customization.md
  8. 94 0
      .agents/skills/shadcn-ui/vendor/shadcn/mcp.md
  9. 306 0
      .agents/skills/shadcn-ui/vendor/shadcn/rules/base-vs-radix.md
  10. 195 0
      .agents/skills/shadcn-ui/vendor/shadcn/rules/composition.md
  11. 192 0
      .agents/skills/shadcn-ui/vendor/shadcn/rules/forms.md
  12. 101 0
      .agents/skills/shadcn-ui/vendor/shadcn/rules/icons.md
  13. 162 0
      .agents/skills/shadcn-ui/vendor/shadcn/rules/styling.md
  14. 2934 0
      .agents/skills/vercel-react-best-practices/AGENTS.md
  15. 2 0
      .env.example
  16. 14 6
      .github/ISSUE_TEMPLATE/bug_report.md
  17. 15 7
      .github/ISSUE_TEMPLATE/bug_report_en.md
  18. 6 3
      .github/ISSUE_TEMPLATE/config.yml
  19. 14 5
      .github/ISSUE_TEMPLATE/feature_request.md
  20. 15 7
      .github/ISSUE_TEMPLATE/feature_request_en.md
  21. 28 0
      .github/PULL_REQUEST_TEMPLATE.md
  22. 12 2
      .github/SECURITY.md
  23. 141 0
      .github/workflows/docker-build.yml
  24. 39 11
      .github/workflows/docker-image-alpha.yml
  25. 113 0
      .github/workflows/docker-image-nightly.yml
  26. 33 0
      .github/workflows/pr-check.yml
  27. 61 23
      .github/workflows/release.yml
  28. 9 1
      .gitignore
  29. 15 10
      AGENTS.md
  30. 15 10
      CLAUDE.md
  31. 18 7
      Dockerfile
  32. 35 0
      Dockerfile.dev
  33. 459 0
      README.en.md
  34. 1 1
      README.fr.md
  35. 1 1
      README.ja.md
  36. 1 1
      README.md
  37. 2 2
      README.zh_CN.md
  38. 12 9
      README.zh_TW.md
  39. 27 1
      common/constants.go
  40. 15 4
      common/email.go
  41. 26 0
      common/embed-file-system.go
  42. 1 0
      common/gin.go
  43. 6 1
      common/init.go
  44. 16 0
      common/json.go
  45. 43 0
      common/json_test.go
  46. 71 27
      common/ssrf_protection.go
  47. 15 8
      common/sys_log.go
  48. 1 0
      constant/context_key.go
  49. 16 0
      constant/waffo_pay_method.go
  50. 107 21
      controller/channel-test.go
  51. 10 9
      controller/channel.go
  52. 71 0
      controller/channel_test_internal_test.go
  53. 31 7
      controller/channel_upstream_update.go
  54. 16 0
      controller/channel_upstream_update_test.go
  55. 223 0
      controller/discord.go
  56. 220 0
      controller/github.go
  57. 268 0
      controller/linuxdo.go
  58. 15 22
      controller/misc.go
  59. 3 5
      controller/model.go
  60. 242 0
      controller/model_list_test.go
  61. 3 1
      controller/oauth.go
  62. 228 0
      controller/oidc.go
  63. 78 5
      controller/option.go
  64. 79 0
      controller/passkey.go
  65. 100 0
      controller/payment_webhook_availability.go
  66. 166 0
      controller/payment_webhook_availability_test.go
  67. 69 0
      controller/perf_metrics.go
  68. 183 0
      controller/performance.go
  69. 27 1
      controller/pricing.go
  70. 24 0
      controller/rankings.go
  71. 161 46
      controller/ratio_sync.go
  72. 13 4
      controller/relay.go
  73. 46 93
      controller/secure_verification.go
  74. 19 16
      controller/subscription_payment_creem.go
  75. 11 10
      controller/subscription_payment_epay.go
  76. 10 9
      controller/subscription_payment_stripe.go
  77. 313 0
      controller/task_video.go
  78. 60 12
      controller/token.go
  79. 541 0
      controller/token_test.go
  80. 153 62
      controller/topup.go
  81. 64 71
      controller/topup_creem.go
  82. 129 58
      controller/topup_stripe.go
  83. 418 0
      controller/topup_waffo.go
  84. 260 0
      controller/topup_waffo_pancake.go
  85. 91 0
      controller/topup_waffo_pancake_test.go
  86. 8 4
      controller/twofa.go
  87. 15 0
      controller/usedata.go
  88. 88 9
      controller/user.go
  89. 11 1
      controller/video_proxy.go
  90. 15 2
      controller/wechat.go
  91. 73 0
      docker-compose.dev.yml
  92. 46 45
      docker-compose.yml
  93. 150 2
      docs/installation/BT.md
  94. 53 1
      docs/openapi/relay.json
  95. 10 0
      dto/audio.go
  96. 1 0
      dto/channel_settings.go
  97. 49 32
      dto/claude.go
  98. 15 46
      dto/gemini.go
  99. 73 0
      dto/gemini_isstream_test.go
  100. 9 7
      dto/openai_image.go

+ 83 - 0
.agents/skills/classic-to-default-sync/SKILL.md

@@ -0,0 +1,83 @@
+---
+name: classic-to-default-sync
+description: Inspect a given commit's web/classic changes and sync all features/fixes to web/default. Use when the user provides a commit ID and wants to audit whether web/default already has the same features as web/classic, port missing features, improve suboptimal implementations, fix bugs, and remove redundant code. Trigger phrases include: "/classic-to-default-sync <hash>", "classic-to-default-sync <hash>", "sync classic to default", "port from classic", "compare classic commit", "classic 和 default 对比", "把这次 classic 的修改同步到 default", "查看这次提交 classic 中的修改并同步", or any request supplying a commit hash together with classic/default comparison intent.
+---
+
+# Classic-to-Default Sync
+
+Given a **commit ID**, audit all `web/classic` changes and ensure `web/default` reaches feature parity with the best possible implementation.
+
+## Input
+
+The user must supply a `<commit-id>`.
+
+## Workflow
+
+### Step 1 — Extract classic diff
+
+```bash
+git show <commit-id> -- web/classic
+```
+
+Read every changed file in `web/classic`. Identify the **logical changes** (new features, UI/UX improvements, bug fixes, config tweaks, removed dead code, etc.) — not just line diffs.
+
+### Step 2 — Map to default counterparts
+
+For each logical change found in Step 1, locate the equivalent file(s) in `web/default/src/`. Use Glob/Grep/SemanticSearch as needed. Consider that:
+
+- `web/classic` uses **React 18 + Vite + Semi Design**
+- `web/default` uses **React 19 + Rsbuild + Base UI + Tailwind CSS**
+- Component names, file paths, and API shapes may differ; match by **functionality**, not filename.
+
+### Step 3 — Triage each change
+
+Classify every logical change as one of:
+
+| Status | Meaning |
+|--------|---------|
+| ✅ Already present & optimal | No action needed |
+| ⚠️ Present but suboptimal | Improve: logic, layout, style, or code quality |
+| ❌ Missing | Implement from scratch in default's stack |
+
+### Step 4 — Implement
+
+For each **⚠️** or **❌** item:
+
+1. **Read the target file(s) in `web/default`** before editing (required by project conventions).
+2. Implement using `web/default` conventions:
+   - React 19 patterns (hooks, Suspense, etc.)
+   - Base UI primitives where applicable
+   - Tailwind CSS for styling (no inline styles or Semi Design imports)
+   - `useTranslation()` + `t('English key')` for all user-visible strings
+   - TypeScript — explicit types, no `any`
+   - No dead code, no redundant comments
+3. Follow **Rule 6** (pointer types for optional relay DTOs) if touching relay-related TS types.
+4. After editing, run `ReadLints` on changed files and fix any introduced lint errors.
+
+### Step 5 — i18n
+
+If any new user-visible strings were added, run the i18n sync:
+
+```bash
+cd web/default && bun run i18n:sync
+```
+
+Then add missing translations for all supported locales (en, zh, fr, ja, ru, vi) following the **i18n-translate** skill.
+
+### Step 6 — Report
+
+Summarise the work in a concise table:
+
+| # | Change (from classic commit) | Status | Action taken |
+|---|------------------------------|--------|--------------|
+| 1 | … | ✅ / ⚠️ / ❌ | None / Improved / Implemented |
+
+If every item is ✅ with no action needed, simply reply: **"已完成 — web/default 已具备此次提交的所有功能,且实现质量良好,无需修改。"**
+
+## Quality bar
+
+- No unused imports, variables, or components
+- No commented-out code left behind
+- Consistent naming with surrounding `web/default` code
+- All interactive elements accessible (keyboard nav, ARIA labels where Radix doesn't provide them automatically)
+- No regressions: existing behaviour in `web/default` must not break

+ 254 - 0
.agents/skills/i18n-translate/SKILL.md

@@ -0,0 +1,254 @@
+---
+name: i18n-translate
+description: >-
+  Complete and maintain frontend i18n translations for this project. Covers
+  finding missing translation keys, detecting untranslated entries, and adding
+  translations for all supported locales (en, zh, fr, ja, ru, vi). Use when the
+  user asks to add translations, fix i18n, complete missing translations, or
+  when new UI text needs to be internationalized.
+---
+
+# Frontend i18n Translation Workflow
+
+## Overview
+
+- Locale files: `web/default/src/i18n/locales/{en,zh,fr,ja,ru,vi}.json`
+- Format: flat JSON under `"translation"` key, keys are English source strings
+- Base locale: `en.json` (most keys), fallback: `zh` (Chinese)
+- Sync script: `bun run i18n:sync` (from `web/default/`)
+- All `t()` calls must have corresponding keys in every locale file
+
+## Workflow
+
+### Step 1: Run sync and read report
+
+```bash
+cd web/default && bun run i18n:sync
+```
+
+Read `web/default/src/i18n/locales/_reports/_sync-report.json` to see per-locale status (missingCount, extrasCount, untranslatedCount).
+
+### Step 2: Find missing keys (used in code but not in locale files)
+
+Create and run `web/default/scripts/find-missing-keys.mjs`:
+
+```javascript
+import fs from 'node:fs/promises'
+import path from 'node:path'
+
+const LOCALES_DIR = path.resolve('src/i18n/locales')
+const SRC_DIR = path.resolve('src')
+
+const en = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, 'en.json'), 'utf8'))
+const enKeys = new Set(Object.keys(en.translation))
+
+const tCallRegex = /\bt\(\s*['"`]([^'"`\n]+?)['"`]\s*[,)]/g
+const tCallMultilineRegex = /\bt\(\s*['"`]([^'"`]+?)['"`]\s*\)/g
+
+async function walkDir(dir) {
+  const files = []
+  const entries = await fs.readdir(dir, { withFileTypes: true })
+  for (const entry of entries) {
+    const fullPath = path.join(dir, entry.name)
+    if (entry.isDirectory()) {
+      if (['node_modules', '.git', 'locales', '_reports', '_extras'].includes(entry.name)) continue
+      files.push(...(await walkDir(fullPath)))
+    } else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
+      files.push(fullPath)
+    }
+  }
+  return files
+}
+
+const files = await walkDir(SRC_DIR)
+const missingKeys = new Map()
+
+for (const file of files) {
+  const content = await fs.readFile(file, 'utf8')
+  const relPath = path.relative(SRC_DIR, file)
+  for (const regex of [tCallRegex, tCallMultilineRegex]) {
+    regex.lastIndex = 0
+    let match
+    while ((match = regex.exec(content)) !== null) {
+      const key = match[1]
+      if (key.startsWith('{{') || key.includes('${')) continue
+      if (!enKeys.has(key)) {
+        if (!missingKeys.has(key)) missingKeys.set(key, [])
+        missingKeys.get(key).push(relPath)
+      }
+    }
+  }
+}
+
+if (missingKeys.size === 0) {
+  console.log('All t() keys found in en.json!')
+} else {
+  console.log(`Found ${missingKeys.size} missing keys:\n`)
+  for (const [key, files] of [...missingKeys.entries()].sort(([a], [b]) => a.localeCompare(b))) {
+    console.log(`  "${key}"`)
+    for (const f of [...new Set(files)]) console.log(`    -> ${f}`)
+  }
+}
+```
+
+### Step 3: Find untranslated entries (value equals English)
+
+Create and run `web/default/scripts/find-untranslated.mjs`:
+
+```javascript
+import fs from 'node:fs/promises'
+import path from 'node:path'
+
+const LOCALES_DIR = path.resolve('src/i18n/locales')
+const en = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, 'en.json'), 'utf8'))
+const enTrans = en.translation
+
+// Brand names, URLs, technical terms — skip these
+const skipPatterns = [
+  /^https?:\/\//, /^smtp\./, /^socks5:/, /^name@/, /^noreply@/,
+  /^org-/, /^price_/, /^whsec_/, /^edit_this$/, /^my-status$/,
+  /^_copy$/, /^gpt-/, /^checkout\./, /^footer\./, /^\[?\{/,
+  /^"default/, /^\/status\//, /^\/your\//, /^example\.com/,
+  /^AZURE_/, /^AccessKey/, /^OAuth/, /^Client /, /^Webhook URL/,
+  /^API URL$/, /^Well-Known/, /^Worker URL$/, /^Uptime Kuma/,
+  /^New API/, /^Baidu V2$/, /^Zhipu V4$/, /^Quota:$/,
+]
+
+const brandNames = new Set([
+  'AIGC2D','Anthropic','API2GPT','Claude','Cloudflare','Cohere','DeepSeek',
+  'Discord','DoubaoVideo','FastGPT','Gemini','GitHub','Jimeng','JustSong',
+  'LingYiWanWu','LinuxDO','Midjourney','MidjourneyPlus','MiniMax','Mistral',
+  'MokaAI','Moonshot','NewAPI','OhMyGPT','Ollama','OpenAI','OpenAIMax',
+  'OpenRouter','Passkey','Perplexity','QuantumNous','Replicate','SiliconFlow',
+  'Stripe','Submodel','SunoAPI','Telegram','Tencent','Vertex AI','VolcEngine',
+  'WeChat','Xinference','Xunfei','AI Proxy','One API',
+])
+
+const locales = ['fr', 'ja', 'ru', 'zh', 'vi']
+
+for (const locale of locales) {
+  const locFile = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, `${locale}.json`), 'utf8'))
+  const locTrans = locFile.translation
+  const untranslated = {}
+
+  for (const [key, enVal] of Object.entries(enTrans)) {
+    const locVal = locTrans[key]
+    if (locVal === undefined || locVal !== enVal) continue
+    if (brandNames.has(key)) continue
+    if (skipPatterns.some(p => p.test(key))) continue
+    if (typeof enVal === 'string' && enVal.length < 4) continue
+    if (/[a-zA-Z]{3,}/.test(String(enVal))) untranslated[key] = enVal
+  }
+
+  const count = Object.keys(untranslated).length
+  if (count > 0) {
+    console.log(`\n=== ${locale} (${count} untranslated) ===`)
+    for (const [k, v] of Object.entries(untranslated))
+      console.log(`  ${JSON.stringify(k)}: ${JSON.stringify(v)}`)
+  } else {
+    console.log(`\n=== ${locale}: all translated ===`)
+  }
+}
+```
+
+### Step 4: Add translations
+
+Create `web/default/scripts/add-missing-keys.mjs` with this structure:
+
+```javascript
+import fs from 'node:fs/promises'
+import path from 'node:path'
+
+const LOCALES_DIR = path.resolve('src/i18n/locales')
+
+function stableStringify(obj) {
+  return JSON.stringify(obj, null, 2) + '\n'
+}
+
+const newKeys = {
+  en: { /* "key": "English value" */ },
+  zh: { /* "key": "中文翻译" */ },
+  fr: { /* "key": "Traduction française" */ },
+  ja: { /* "key": "日本語翻訳" */ },
+  ru: { /* "key": "Русский перевод" */ },
+  vi: { /* "key": "Bản dịch tiếng Việt" */ },
+}
+
+async function main() {
+  let totalAdded = 0
+
+  for (const [locale, trans] of Object.entries(newKeys)) {
+    const filePath = path.join(LOCALES_DIR, `${locale}.json`)
+    const json = JSON.parse(await fs.readFile(filePath, 'utf8'))
+
+    let count = 0
+    for (const [key, value] of Object.entries(trans)) {
+      if (!Object.prototype.hasOwnProperty.call(json.translation, key)) {
+        json.translation[key] = value
+        count++
+      } else if (json.translation[key] !== value) {
+        json.translation[key] = value
+        count++
+      }
+    }
+
+    if (count > 0) {
+      json.translation = Object.fromEntries(
+        Object.entries(json.translation).sort(([a], [b]) => a.localeCompare(b))
+      )
+      await fs.writeFile(filePath, stableStringify(json), 'utf8')
+    }
+
+    console.log(`${locale}: ${count} translations applied`)
+    totalAdded += count
+  }
+
+  console.log(`\nTotal: ${totalAdded} translations applied`)
+}
+
+main().catch((err) => { console.error(err); process.exitCode = 1 })
+```
+
+Populate the `newKeys` object with actual translations for each locale.
+
+### Step 5: Verify and clean up
+
+```bash
+cd web/default
+node scripts/add-missing-keys.mjs   # apply translations
+node scripts/find-missing-keys.mjs  # verify: should say "All t() keys found"
+bun run i18n:sync                   # normalize file order
+```
+
+Delete temporary scripts after completion.
+
+## Translation Guidelines
+
+| Language | Code | Notes |
+|----------|------|-------|
+| English | en | Base locale, key = value |
+| Chinese | zh | Fallback locale, must be complete |
+| French | fr | Many English cognates are valid (e.g., "Configuration") |
+| Japanese | ja | Use katakana for technical loanwords |
+| Russian | ru | Use formal register |
+| Vietnamese | vi | Use standard Vietnamese |
+
+**Keep as English (do not translate):**
+- Brand/product names (OpenAI, Claude, Gemini, etc.)
+- URLs and email placeholders
+- Technical identifiers (JSON keys, API paths, model names)
+- Code-like strings (gpt-3.5-turbo, price_xxx, etc.)
+
+**Always translate:**
+- UI labels, button text, error messages, descriptions
+- Time units (hours, minutes, months, years)
+- Action words (Move, Show, Delete, etc.)
+
+## Key Rules
+
+1. All scripts run from `web/default/` directory
+2. Use `node scripts/xxx.mjs` (ESM format with top-level await)
+3. Sort keys alphabetically when writing locale files
+4. Always run `bun run i18n:sync` as the final step
+5. Delete temporary scripts after completion
+6. The `{{variable}}` placeholders in keys must be preserved in all translations

+ 105 - 0
.agents/skills/shadcn-ui/SKILL.md

@@ -0,0 +1,105 @@
+---
+name: shadcn-ui
+description: >-
+  Give the assistant project-aware shadcn/ui context: components.json,
+  composition patterns, CLI, registries, theming, and MCP. Use when working on
+  web/default UI, shadcn components, or presets. Overview aligns with
+  https://ui.shadcn.com/docs/skills.md; full upstream skill text is vendored
+  under vendor/shadcn/.
+---
+
+<!-- Canonical overview: https://ui.shadcn.com/docs/skills.md -->
+
+# Skills (shadcn/ui)
+
+Skills give AI assistants project-aware context about shadcn/ui. When used, the assistant knows how to find, install, compose, and customize components using the correct APIs and patterns for your project.
+
+For example, you can ask:
+
+- _"Add a login form with email and password fields."_
+- _"Create a settings page with a form for updating profile information."_
+- _"Build a dashboard with a sidebar, stats cards, and a data table."_
+- _"Switch to --preset [CODE]"_
+- _"Can you add a hero from @tailark?"_
+
+The skill reads your project's `components.json` and provides your framework, aliases, installed components, icon library, and base library so it can generate correct code on the first try.
+
+---
+
+## Install (ecosystem vs this repo)
+
+Official install from [Skills — shadcn/ui](https://ui.shadcn.com/docs/skills.md):
+
+```bash
+npx skills add shadcn/ui
+```
+
+That installs the skill where the `skills` CLI is available. **This repository** keeps the same intent under `.agents/skills/shadcn-ui/` (overview here + **vendored** upstream docs in [`vendor/shadcn/`](./vendor/shadcn/)) and runs the shadcn CLI from the frontend app root:
+
+```bash
+cd web/default && bunx shadcn@latest info --json
+```
+
+Learn more about skills at [skills.sh](https://skills.sh).
+
+---
+
+## What's included (and where)
+
+### Project context
+
+Run **`shadcn info --json`** (here: `cd web/default && bunx shadcn@latest info --json`) for framework, Tailwind version, aliases, base (`radix` | `base`), icon library, installed components, and resolved paths.
+
+### CLI commands
+
+Full command reference (vendored): [`vendor/shadcn/cli.md`](./vendor/shadcn/cli.md).
+
+### Theming and customization
+
+Vendored: [`vendor/shadcn/customization.md`](./vendor/shadcn/customization.md). Live docs: [Theming](https://ui.shadcn.com/docs/theming).
+
+### Registry authoring
+
+Not duplicated as a single file in the vendor tree; see [Registry](https://ui.shadcn.com/docs/registry) and `build` in [`vendor/shadcn/cli.md`](./vendor/shadcn/cli.md).
+
+### MCP server
+
+Vendored: [`vendor/shadcn/mcp.md`](./vendor/shadcn/mcp.md). Live docs: [MCP Server](https://ui.shadcn.com/docs/mcp).
+
+---
+
+## How it works
+
+1. **Project detection** — Applies when `components.json` exists (here: `web/default/components.json`).
+2. **Context injection** — Use `shadcn info --json` as ground truth for imports and APIs.
+3. **Pattern enforcement** — Follow rules in [`vendor/shadcn/SKILL.md`](./vendor/shadcn/SKILL.md) and [`vendor/shadcn/rules/`](./vendor/shadcn/rules/).
+4. **Component discovery** — `shadcn docs`, `shadcn search`, MCP, or registries — see vendored SKILL + MCP doc.
+
+---
+
+## Learn more (web)
+
+- [CLI](https://ui.shadcn.com/docs/cli) — complements [`vendor/shadcn/cli.md`](./vendor/shadcn/cli.md)
+- [Theming](https://ui.shadcn.com/docs/theming)
+- [Registry](https://ui.shadcn.com/docs/registry)
+- [skills.sh](https://skills.sh)
+
+---
+
+## Vendored upstream bundle (deep rules)
+
+Snapshot from [shadcn-ui/ui `skills/shadcn`](https://github.com/shadcn-ui/ui/tree/main/skills/shadcn); revision note in [`vendor/shadcn/UPSTREAM.txt`](./vendor/shadcn/UPSTREAM.txt).
+
+| Doc | Path |
+| --- | --- |
+| Full official skill body | [`vendor/shadcn/SKILL.md`](./vendor/shadcn/SKILL.md) |
+| CLI reference | [`vendor/shadcn/cli.md`](./vendor/shadcn/cli.md) |
+| Theming / customization | [`vendor/shadcn/customization.md`](./vendor/shadcn/customization.md) |
+| MCP | [`vendor/shadcn/mcp.md`](./vendor/shadcn/mcp.md) |
+| Forms | [`vendor/shadcn/rules/forms.md`](./vendor/shadcn/rules/forms.md) |
+| Composition | [`vendor/shadcn/rules/composition.md`](./vendor/shadcn/rules/composition.md) |
+| Icons | [`vendor/shadcn/rules/icons.md`](./vendor/shadcn/rules/icons.md) |
+| Styling | [`vendor/shadcn/rules/styling.md`](./vendor/shadcn/rules/styling.md) |
+| Base vs Radix | [`vendor/shadcn/rules/base-vs-radix.md`](./vendor/shadcn/rules/base-vs-radix.md) |
+
+**Workflow:** Prefer this **root** `SKILL.md` for repo paths (`web/default`, Bun). Read **`vendor/shadcn/SKILL.md`** for the complete upstream workflow, patterns, and CLI quick reference. Use **`vendor/shadcn/rules/*.md`** when validating concrete markup.

+ 260 - 0
.agents/skills/shadcn-ui/vendor/shadcn/SKILL.md

@@ -0,0 +1,260 @@
+---
+name: shadcn
+description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
+user-invocable: false
+allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
+---
+
+# shadcn/ui
+
+A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
+
+> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
+
+## Current Project Context
+
+```json
+!`npx shadcn@latest info --json`
+```
+
+The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
+
+## Principles
+
+1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
+2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
+3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
+4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
+
+## Critical Rules
+
+These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
+
+### Styling & Tailwind → [styling.md](./rules/styling.md)
+
+- **`className` for layout, not styling.** Never override component colors or typography.
+- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
+- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
+- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
+- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
+- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
+- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
+
+### Forms & Inputs → [forms.md](./rules/forms.md)
+
+- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
+- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
+- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
+- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
+- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
+- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
+
+### Component Structure → [composition.md](./rules/composition.md)
+
+- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`.
+- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
+- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
+- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
+- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
+- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
+- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
+
+### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
+
+- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
+- **Callouts use `Alert`.** Don't build custom styled divs.
+- **Empty states use `Empty`.** Don't build custom empty state markup.
+- **Toast via `sonner`.** Use `toast()` from `sonner`.
+- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
+- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
+- **Use `Badge`** instead of custom styled spans.
+
+### Icons → [icons.md](./rules/icons.md)
+
+- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
+- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
+- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
+
+### CLI
+
+- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
+- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
+
+## Key Patterns
+
+These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
+
+```tsx
+// Form layout: FieldGroup + Field, not div + Label.
+<FieldGroup>
+  <Field>
+    <FieldLabel htmlFor="email">Email</FieldLabel>
+    <Input id="email" />
+  </Field>
+</FieldGroup>
+
+// Validation: data-invalid on Field, aria-invalid on the control.
+<Field data-invalid>
+  <FieldLabel>Email</FieldLabel>
+  <Input aria-invalid />
+  <FieldDescription>Invalid email.</FieldDescription>
+</Field>
+
+// Icons in buttons: data-icon, no sizing classes.
+<Button>
+  <SearchIcon data-icon="inline-start" />
+  Search
+</Button>
+
+// Spacing: gap-*, not space-y-*.
+<div className="flex flex-col gap-4">  // correct
+<div className="space-y-4">           // wrong
+
+// Equal dimensions: size-*, not w-* h-*.
+<Avatar className="size-10">   // correct
+<Avatar className="w-10 h-10"> // wrong
+
+// Status colors: Badge variants or semantic tokens, not raw colors.
+<Badge variant="secondary">+20.1%</Badge>    // correct
+<span className="text-emerald-600">+20.1%</span> // wrong
+```
+
+## Component Selection
+
+| Need                       | Use                                                                                                 |
+| -------------------------- | --------------------------------------------------------------------------------------------------- |
+| Button/action              | `Button` with appropriate variant                                                                   |
+| Form inputs                | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
+| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem`                                                                   |
+| Data display               | `Table`, `Card`, `Badge`, `Avatar`                                                                  |
+| Navigation                 | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination`                                     |
+| Overlays                   | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation)       |
+| Feedback                   | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner`                                        |
+| Command palette            | `Command` inside `Dialog`                                                                           |
+| Charts                     | `Chart` (wraps Recharts)                                                                            |
+| Layout                     | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible`                          |
+| Empty states               | `Empty`                                                                                             |
+| Menus                      | `DropdownMenu`, `ContextMenu`, `Menubar`                                                            |
+| Tooltips/info              | `Tooltip`, `HoverCard`, `Popover`                                                                   |
+
+## Key Fields
+
+The injected project context contains these key fields:
+
+- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
+- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
+- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
+- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
+- **`style`** → component visual treatment (e.g. `nova`, `vega`).
+- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
+- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
+- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
+- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
+- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
+- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information.
+
+See [cli.md — `info` command](./cli.md) for the full field reference.
+
+## Component Docs, Examples, and Usage
+
+Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
+
+```bash
+npx shadcn@latest docs button dialog select
+```
+
+**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
+
+## Workflow
+
+1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
+2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
+3. **Find components** — `npx shadcn@latest search`.
+4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
+5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
+6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
+7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
+8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
+9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
+   - **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
+   - **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
+   - **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables.
+   - **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
+   - **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
+   - **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
+   - **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
+
+## Updating Components
+
+When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
+
+1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
+2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
+3. Decide per file based on the diff:
+   - No local changes → safe to overwrite.
+   - Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
+   - User says "just update everything" → use `--overwrite`, but confirm first.
+4. **Never use `--overwrite` without the user's explicit approval.**
+
+## Quick Reference
+
+```bash
+# Create a new project.
+npx shadcn@latest init --name my-app --preset base-nova
+npx shadcn@latest init --name my-app --preset a2r6bw --template vite
+
+# Create a monorepo project.
+npx shadcn@latest init --name my-app --preset base-nova --monorepo
+npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
+
+# Initialize existing project.
+npx shadcn@latest init --preset base-nova
+npx shadcn@latest init --defaults  # shortcut: --template=next --preset=nova (base style implied)
+
+# Apply a preset to an existing project.
+npx shadcn@latest apply a2r6bw
+npx shadcn@latest apply a2r6bw --only theme
+npx shadcn@latest apply a2r6bw --only font
+npx shadcn@latest apply a2r6bw --only theme,font
+
+# Inspect preset codes and project preset state.
+npx shadcn@latest preset decode a2r6bw
+npx shadcn@latest preset url a2r6bw
+npx shadcn@latest preset open a2r6bw
+npx shadcn@latest preset resolve
+npx shadcn@latest preset resolve --json
+
+# Add components.
+npx shadcn@latest add button card dialog
+npx shadcn@latest add @magicui/shimmer-button
+npx shadcn@latest add --all
+
+# Preview changes before adding/updating.
+npx shadcn@latest add button --dry-run
+npx shadcn@latest add button --diff button.tsx
+npx shadcn@latest add @acme/form --view button.tsx
+
+# Search registries.
+npx shadcn@latest search @shadcn -q "sidebar"
+npx shadcn@latest search @tailark -q "stats"
+
+# Get component docs and example URLs.
+npx shadcn@latest docs button dialog select
+
+# View registry item details (for items not yet installed).
+npx shadcn@latest view @shadcn/button
+```
+
+**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
+**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
+**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com).
+
+## Detailed References
+
+- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
+- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
+- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
+- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
+- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
+- [cli.md](./cli.md) — Commands, flags, presets, templates
+- [customization.md](./customization.md) — Theming, CSS variables, extending components

+ 3 - 0
.agents/skills/shadcn-ui/vendor/shadcn/UPSTREAM.txt

@@ -0,0 +1,3 @@
+Source: https://github.com/shadcn-ui/ui/tree/56161142f1b83f612462772d18883807b5f0d601/skills/shadcn
+Branch: main
+Fetched: 2026-04-29

+ 276 - 0
.agents/skills/shadcn-ui/vendor/shadcn/cli.md

@@ -0,0 +1,276 @@
+# shadcn CLI Reference
+
+Configuration is read from `components.json`.
+
+> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
+
+> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
+
+## Contents
+
+- Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build
+- Templates: next, vite, start, react-router, astro
+- Presets: named, code, URL formats and fields
+- Switching presets
+
+---
+
+## Commands
+
+### `init` — Initialize or create a project
+
+```bash
+npx shadcn@latest init [components...] [options]
+```
+
+Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
+
+| Flag                    | Short | Description                                               | Default |
+| ----------------------- | ----- | --------------------------------------------------------- | ------- |
+| `--template <template>` | `-t`  | Template (next, start, vite, next-monorepo, react-router) | —       |
+| `--preset [name]`       | `-p`  | Preset configuration (named, code, or URL)                | —       |
+| `--yes`                 | `-y`  | Skip confirmation prompt                                  | `true`  |
+| `--defaults`            | `-d`  | Use defaults (`--template=next --preset=base-nova`)       | `false` |
+| `--force`               | `-f`  | Force overwrite existing configuration                    | `false` |
+| `--cwd <cwd>`           | `-c`  | Working directory                                         | current |
+| `--name <name>`         | `-n`  | Name for new project                                      | —       |
+| `--silent`              | `-s`  | Mute output                                               | `false` |
+| `--rtl`                 |       | Enable RTL support                                        | —       |
+| `--reinstall`           |       | Re-install existing UI components                         | `false` |
+| `--monorepo`            |       | Scaffold a monorepo project                               | —       |
+| `--no-monorepo`         |       | Skip the monorepo prompt                                  | —       |
+
+`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
+
+### `apply` — Apply a preset to an existing project
+
+```bash
+npx shadcn@latest apply [preset] [options]
+```
+
+Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components.
+
+| Flag                | Short | Description                                | Default |
+| ------------------- | ----- | ------------------------------------------ | ------- |
+| `--preset <preset>` | —     | Preset configuration (named, code, or URL) | —       |
+| `--yes`             | `-y`  | Skip confirmation prompt                   | `false` |
+| `--cwd <cwd>`       | `-c`  | Working directory                          | current |
+| `--silent`          | `-s`  | Mute output                                | `false` |
+
+`[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match.
+If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
+
+### `add` — Add components
+
+> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
+
+```bash
+npx shadcn@latest add [components...] [options]
+```
+
+Accepts component names, registry-prefixed names (`@magicui/shimmer-button`), URLs, or local paths.
+
+| Flag            | Short | Description                                                                                                          | Default |
+| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
+| `--yes`         | `-y`  | Skip confirmation prompt                                                                                             | `false` |
+| `--overwrite`   | `-o`  | Overwrite existing files                                                                                             | `false` |
+| `--cwd <cwd>`   | `-c`  | Working directory                                                                                                    | current |
+| `--all`         | `-a`  | Add all available components                                                                                         | `false` |
+| `--path <path>` | `-p`  | Target path for the component                                                                                        | —       |
+| `--silent`      | `-s`  | Mute output                                                                                                          | `false` |
+| `--dry-run`     |       | Preview all changes without writing files                                                                            | `false` |
+| `--diff [path]` |       | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`)         | —       |
+| `--view [path]` |       | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | —       |
+
+#### Dry-Run Mode
+
+Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
+
+```bash
+# Preview all changes.
+npx shadcn@latest add button --dry-run
+
+# Show diffs for all files (top 5).
+npx shadcn@latest add button --diff
+
+# Show the diff for a specific file.
+npx shadcn@latest add button --diff button.tsx
+
+# Show contents for all files (top 5).
+npx shadcn@latest add button --view
+
+# Show the full content of a specific file.
+npx shadcn@latest add button --view button.tsx
+
+# Works with URLs too.
+npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
+
+# CSS diffs.
+npx shadcn@latest add button --diff globals.css
+```
+
+**When to use dry-run:**
+
+- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
+- Before overwriting existing components — use `--diff` to preview the changes first.
+- When the user wants to inspect component source code without installing — use `--view`.
+- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
+- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
+
+> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
+
+#### Smart Merge from Upstream
+
+See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
+
+### `search` — Search registries
+
+```bash
+npx shadcn@latest search <registries...> [options]
+```
+
+Fuzzy search across registries. Also aliased as `npx shadcn@latest list`. Without `-q`, lists all items.
+
+| Flag                | Short | Description            | Default |
+| ------------------- | ----- | ---------------------- | ------- |
+| `--query <query>`   | `-q`  | Search query           | —       |
+| `--limit <number>`  | `-l`  | Max items per registry | `100`   |
+| `--offset <number>` | `-o`  | Items to skip          | `0`     |
+| `--cwd <cwd>`       | `-c`  | Working directory      | current |
+
+### `view` — View item details
+
+```bash
+npx shadcn@latest view <items...> [options]
+```
+
+Displays item info including file contents. Example: `npx shadcn@latest view @shadcn/button`.
+
+### `docs` — Get component documentation URLs
+
+```bash
+npx shadcn@latest docs <components...> [options]
+```
+
+Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
+
+Example output for `npx shadcn@latest docs input button`:
+
+```
+base  radix
+
+input
+  docs      https://ui.shadcn.com/docs/components/radix/input
+  examples  https://raw.githubusercontent.com/.../examples/input-example.tsx
+
+button
+  docs      https://ui.shadcn.com/docs/components/radix/button
+  examples  https://raw.githubusercontent.com/.../examples/button-example.tsx
+```
+
+Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
+
+### `diff` — Check for updates
+
+Do not use this command. Use `npx shadcn@latest add --diff` instead.
+
+### `info` — Project information
+
+```bash
+npx shadcn@latest info [options]
+```
+
+Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
+
+| Flag          | Short | Description       | Default |
+| ------------- | ----- | ----------------- | ------- |
+| `--cwd <cwd>` | `-c`  | Working directory | current |
+
+**Project Info fields:**
+
+| Field                | Type      | Meaning                                                            |
+| -------------------- | --------- | ------------------------------------------------------------------ |
+| `framework`          | `string`  | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
+| `frameworkVersion`   | `string`  | Framework version (e.g. `15.2.4`)                                  |
+| `isSrcDir`           | `boolean` | Whether the project uses a `src/` directory                        |
+| `isRSC`              | `boolean` | Whether React Server Components are enabled                        |
+| `isTsx`              | `boolean` | Whether the project uses TypeScript                                |
+| `tailwindVersion`    | `string`  | `"v3"` or `"v4"`                                                   |
+| `tailwindConfigFile` | `string`  | Path to the Tailwind config file                                   |
+| `tailwindCssFile`    | `string`  | Path to the global CSS file                                        |
+| `aliasPrefix`        | `string`  | Import alias prefix (e.g. `@`, `~`, `@/`)                          |
+| `packageManager`     | `string`  | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`)            |
+
+**Components.json fields:**
+
+| Field                | Type      | Meaning                                                                                    |
+| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
+| `base`               | `string`  | Primitive library (`radix` or `base`) — determines component APIs and available props      |
+| `style`              | `string`  | Visual style (e.g. `nova`, `vega`)                                                         |
+| `rsc`                | `boolean` | RSC flag from config                                                                       |
+| `tsx`                | `boolean` | TypeScript flag                                                                            |
+| `tailwind.config`    | `string`  | Tailwind config path                                                                       |
+| `tailwind.css`       | `string`  | Global CSS path — this is where custom CSS variables go                                    |
+| `iconLibrary`        | `string`  | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
+| `aliases.components` | `string`  | Component import alias (e.g. `@/components`)                                               |
+| `aliases.utils`      | `string`  | Utils import alias (e.g. `@/lib/utils`)                                                    |
+| `aliases.ui`         | `string`  | UI component alias (e.g. `@/components/ui`)                                                |
+| `aliases.lib`        | `string`  | Lib alias (e.g. `@/lib`)                                                                   |
+| `aliases.hooks`      | `string`  | Hooks alias (e.g. `@/hooks`)                                                               |
+| `resolvedPaths`      | `object`  | Absolute file-system paths for each alias                                                  |
+| `registries`         | `object`  | Configured custom registries                                                               |
+
+**Links fields:**
+
+The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
+
+### `build` — Build a custom registry
+
+```bash
+npx shadcn@latest build [registry] [options]
+```
+
+Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
+
+| Flag              | Short | Description       | Default      |
+| ----------------- | ----- | ----------------- | ------------ |
+| `--output <path>` | `-o`  | Output directory  | `./public/r` |
+| `--cwd <cwd>`     | `-c`  | Working directory | current      |
+
+---
+
+## Templates
+
+| Value          | Framework      | Monorepo support |
+| -------------- | -------------- | ---------------- |
+| `next`         | Next.js        | Yes              |
+| `vite`         | Vite           | Yes              |
+| `start`        | TanStack Start | Yes              |
+| `react-router` | React Router   | Yes              |
+| `astro`        | Astro          | Yes              |
+| `laravel`      | Laravel        | No               |
+
+All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
+
+---
+
+## Presets
+
+Three ways to specify a preset via `--preset`:
+
+1. **Named:** `--preset nova` or `--preset lyra`
+2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`)
+3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
+
+> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
+> Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset.
+
+## Switching Presets
+
+Ask the user first: **overwrite**, **merge**, or **skip** existing components?
+
+- **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected component files with the new preset styles. Use when the user hasn't customized components.
+- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
+- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
+
+Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.

+ 209 - 0
.agents/skills/shadcn-ui/vendor/shadcn/customization.md

@@ -0,0 +1,209 @@
+# Customization & Theming
+
+Components reference semantic CSS variable tokens. Change the variables to change every component.
+
+## Contents
+
+- How it works (CSS variables → Tailwind utilities → components)
+- Color variables and OKLCH format
+- Dark mode setup
+- Changing the theme (presets, CSS variables)
+- Adding custom colors (Tailwind v3 and v4)
+- Border radius
+- Customizing components (variants, className, wrappers)
+- Checking for updates
+
+---
+
+## How It Works
+
+1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
+2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
+3. Components use these utilities — changing a variable changes all components that reference it.
+
+---
+
+## Color Variables
+
+Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
+
+| Variable                                     | Purpose                          |
+| -------------------------------------------- | -------------------------------- |
+| `--background` / `--foreground`              | Page background and default text |
+| `--card` / `--card-foreground`               | Card surfaces                    |
+| `--primary` / `--primary-foreground`         | Primary buttons and actions      |
+| `--secondary` / `--secondary-foreground`     | Secondary actions                |
+| `--muted` / `--muted-foreground`             | Muted/disabled states            |
+| `--accent` / `--accent-foreground`           | Hover and accent states          |
+| `--destructive` / `--destructive-foreground` | Error and destructive actions    |
+| `--border`                                   | Default border color             |
+| `--input`                                    | Form input borders               |
+| `--ring`                                     | Focus ring color                 |
+| `--chart-1` through `--chart-5`              | Chart/data visualization         |
+| `--sidebar-*`                                | Sidebar-specific colors          |
+| `--surface` / `--surface-foreground`         | Secondary surface                |
+
+Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
+
+---
+
+## Dark Mode
+
+Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
+
+```tsx
+import { ThemeProvider } from "next-themes"
+
+<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
+  {children}
+</ThemeProvider>
+```
+
+---
+
+## Changing the Theme
+
+```bash
+# Apply a preset code from ui.shadcn.com.
+npx shadcn@latest apply --preset a2r6bw
+
+# Positional shorthand also works.
+npx shadcn@latest apply a2r6bw
+
+# Switch to a named preset and overwrite existing components.
+npx shadcn@latest apply --preset nova
+
+# Preserve existing components instead.
+npx shadcn@latest init --preset nova --force --no-reinstall
+
+# Use a custom theme URL.
+npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
+```
+
+Or edit CSS variables directly in `globals.css`.
+
+---
+
+## Adding Custom Colors
+
+Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
+
+```css
+/* 1. Define in the global CSS file. */
+:root {
+  --warning: oklch(0.84 0.16 84);
+  --warning-foreground: oklch(0.28 0.07 46);
+}
+.dark {
+  --warning: oklch(0.41 0.11 46);
+  --warning-foreground: oklch(0.99 0.02 95);
+}
+```
+
+```css
+/* 2a. Register with Tailwind v4 (@theme inline). */
+@theme inline {
+  --color-warning: var(--warning);
+  --color-warning-foreground: var(--warning-foreground);
+}
+```
+
+When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
+
+```js
+// 2b. Register with Tailwind v3 (tailwind.config.js).
+module.exports = {
+  theme: {
+    extend: {
+      colors: {
+        warning: "oklch(var(--warning) / <alpha-value>)",
+        "warning-foreground":
+          "oklch(var(--warning-foreground) / <alpha-value>)",
+      },
+    },
+  },
+}
+```
+
+```tsx
+// 3. Use in components.
+<div className="bg-warning text-warning-foreground">Warning</div>
+```
+
+---
+
+## Border Radius
+
+`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
+
+---
+
+## Customizing Components
+
+See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
+
+Prefer these approaches in order:
+
+### 1. Built-in variants
+
+```tsx
+<Button variant="outline" size="sm">
+  Click
+</Button>
+```
+
+### 2. Tailwind classes via `className`
+
+```tsx
+<Card className="mx-auto max-w-md">...</Card>
+```
+
+### 3. Add a new variant
+
+Edit the component source to add a variant via `cva`:
+
+```tsx
+// components/ui/button.tsx
+warning: "bg-warning text-warning-foreground hover:bg-warning/90",
+```
+
+### 4. Wrapper components
+
+Compose shadcn/ui primitives into higher-level components:
+
+```tsx
+export function ConfirmDialog({ title, description, onConfirm, children }) {
+  return (
+    <AlertDialog>
+      <AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
+      <AlertDialogContent>
+        <AlertDialogHeader>
+          <AlertDialogTitle>{title}</AlertDialogTitle>
+          <AlertDialogDescription>{description}</AlertDialogDescription>
+        </AlertDialogHeader>
+        <AlertDialogFooter>
+          <AlertDialogCancel>Cancel</AlertDialogCancel>
+          <AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
+        </AlertDialogFooter>
+      </AlertDialogContent>
+    </AlertDialog>
+  )
+}
+```
+
+---
+
+## Checking for Updates
+
+```bash
+npx shadcn@latest add button --diff
+```
+
+To preview exactly what would change before updating, use `--dry-run` and `--diff`:
+
+```bash
+npx shadcn@latest add button --dry-run        # see all affected files
+npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
+```
+
+See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.

+ 94 - 0
.agents/skills/shadcn-ui/vendor/shadcn/mcp.md

@@ -0,0 +1,94 @@
+# shadcn MCP Server
+
+The CLI includes an MCP server that lets AI assistants search, browse, view, and install components from registries.
+
+---
+
+## Setup
+
+```bash
+shadcn mcp        # start the MCP server (stdio)
+shadcn mcp init   # write config for your editor
+```
+
+Editor config files:
+
+| Editor | Config file |
+|--------|------------|
+| Claude Code | `.mcp.json` |
+| Cursor | `.cursor/mcp.json` |
+| VS Code | `.vscode/mcp.json` |
+| OpenCode | `opencode.json` |
+| Codex | `~/.codex/config.toml` (manual) |
+
+---
+
+## Tools
+
+> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
+
+### `shadcn:get_project_registries`
+
+Returns registry names from `components.json`. Errors if no `components.json` exists.
+
+**Input:** none
+
+### `shadcn:list_items_in_registries`
+
+Lists all items from one or more registries.
+
+**Input:** `registries` (string[]), `limit` (number, optional), `offset` (number, optional)
+
+### `shadcn:search_items_in_registries`
+
+Fuzzy search across registries.
+
+**Input:** `registries` (string[]), `query` (string), `limit` (number, optional), `offset` (number, optional)
+
+### `shadcn:view_items_in_registries`
+
+View item details including full file contents.
+
+**Input:** `items` (string[]) — e.g. `["@shadcn/button", "@shadcn/card"]`
+
+### `shadcn:get_item_examples_from_registries`
+
+Find usage examples and demos with source code.
+
+**Input:** `registries` (string[]), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
+
+### `shadcn:get_add_command_for_items`
+
+Returns the CLI install command.
+
+**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
+
+### `shadcn:get_audit_checklist`
+
+Returns a checklist for verifying components (imports, deps, lint, TypeScript).
+
+**Input:** none
+
+---
+
+## Configuring Registries
+
+Registries are set in `components.json`. The `@shadcn` registry is always built-in.
+
+```json
+{
+  "registries": {
+    "@acme": "https://acme.com/r/{name}.json",
+    "@private": {
+      "url": "https://private.com/r/{name}.json",
+      "headers": { "Authorization": "Bearer ${MY_TOKEN}" }
+    }
+  }
+}
+```
+
+- Names must start with `@`.
+- URLs must contain `{name}`.
+- `${VAR}` references are resolved from environment variables.
+
+Community registry index: `https://ui.shadcn.com/r/registries.json`

+ 306 - 0
.agents/skills/shadcn-ui/vendor/shadcn/rules/base-vs-radix.md

@@ -0,0 +1,306 @@
+# Base vs Radix
+
+API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
+
+## Contents
+
+- Composition: asChild vs render
+- Button / trigger as non-button element
+- Select (items prop, placeholder, positioning, multiple, object values)
+- ToggleGroup (type vs multiple)
+- Slider (scalar vs array)
+- Accordion (type and defaultValue)
+
+---
+
+## Composition: asChild (radix) vs render (base)
+
+Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
+
+**Incorrect:**
+
+```tsx
+<DialogTrigger>
+  <div>
+    <Button>Open</Button>
+  </div>
+</DialogTrigger>
+```
+
+**Correct (radix):**
+
+```tsx
+<DialogTrigger asChild>
+  <Button>Open</Button>
+</DialogTrigger>
+```
+
+**Correct (base):**
+
+```tsx
+<DialogTrigger render={<Button />}>Open</DialogTrigger>
+```
+
+This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
+
+---
+
+## Button / trigger as non-button element (base only)
+
+When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
+
+**Incorrect (base):** missing `nativeButton={false}`.
+
+```tsx
+<Button render={<a href="/docs" />}>Read the docs</Button>
+```
+
+**Correct (base):**
+
+```tsx
+<Button render={<a href="/docs" />} nativeButton={false}>
+  Read the docs
+</Button>
+```
+
+**Correct (radix):**
+
+```tsx
+<Button asChild>
+  <a href="/docs">Read the docs</a>
+</Button>
+```
+
+Same for triggers whose `render` is not a `Button`:
+
+```tsx
+// base.
+<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
+  Pick date
+</PopoverTrigger>
+```
+
+---
+
+## Select
+
+**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
+
+**Incorrect (base):**
+
+```tsx
+<Select>
+  <SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
+</Select>
+```
+
+**Correct (base):**
+
+```tsx
+const items = [
+  { label: "Select a fruit", value: null },
+  { label: "Apple", value: "apple" },
+  { label: "Banana", value: "banana" },
+]
+
+<Select items={items}>
+  <SelectTrigger>
+    <SelectValue />
+  </SelectTrigger>
+  <SelectContent>
+    <SelectGroup>
+      {items.map((item) => (
+        <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
+      ))}
+    </SelectGroup>
+  </SelectContent>
+</Select>
+```
+
+**Correct (radix):**
+
+```tsx
+<Select>
+  <SelectTrigger>
+    <SelectValue placeholder="Select a fruit" />
+  </SelectTrigger>
+  <SelectContent>
+    <SelectGroup>
+      <SelectItem value="apple">Apple</SelectItem>
+      <SelectItem value="banana">Banana</SelectItem>
+    </SelectGroup>
+  </SelectContent>
+</Select>
+```
+
+**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
+
+**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
+
+```tsx
+// base.
+<SelectContent alignItemWithTrigger={false} side="bottom">
+
+// radix.
+<SelectContent position="popper">
+```
+
+---
+
+## Select — multiple selection and object values (base only)
+
+Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
+
+**Correct (base — multiple selection):**
+
+```tsx
+<Select items={items} multiple defaultValue={[]}>
+  <SelectTrigger>
+    <SelectValue>
+      {(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
+    </SelectValue>
+  </SelectTrigger>
+  ...
+</Select>
+```
+
+**Correct (base — object values):**
+
+```tsx
+<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
+  <SelectTrigger>
+    <SelectValue>{(value) => value.name}</SelectValue>
+  </SelectTrigger>
+  ...
+</Select>
+```
+
+---
+
+## ToggleGroup
+
+Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
+
+**Incorrect (base):**
+
+```tsx
+<ToggleGroup type="single" defaultValue="daily">
+  <ToggleGroupItem value="daily">Daily</ToggleGroupItem>
+</ToggleGroup>
+```
+
+**Correct (base):**
+
+```tsx
+// Single (no prop needed), defaultValue is always an array.
+<ToggleGroup defaultValue={["daily"]} spacing={2}>
+  <ToggleGroupItem value="daily">Daily</ToggleGroupItem>
+  <ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
+</ToggleGroup>
+
+// Multi-selection.
+<ToggleGroup multiple>
+  <ToggleGroupItem value="bold">Bold</ToggleGroupItem>
+  <ToggleGroupItem value="italic">Italic</ToggleGroupItem>
+</ToggleGroup>
+```
+
+**Correct (radix):**
+
+```tsx
+// Single, defaultValue is a string.
+<ToggleGroup type="single" defaultValue="daily" spacing={2}>
+  <ToggleGroupItem value="daily">Daily</ToggleGroupItem>
+  <ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
+</ToggleGroup>
+
+// Multi-selection.
+<ToggleGroup type="multiple">
+  <ToggleGroupItem value="bold">Bold</ToggleGroupItem>
+  <ToggleGroupItem value="italic">Italic</ToggleGroupItem>
+</ToggleGroup>
+```
+
+**Controlled single value:**
+
+```tsx
+// base — wrap/unwrap arrays.
+const [value, setValue] = React.useState("normal")
+<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
+
+// radix — plain string.
+const [value, setValue] = React.useState("normal")
+<ToggleGroup type="single" value={value} onValueChange={setValue}>
+```
+
+---
+
+## Slider
+
+Base accepts a plain number for a single thumb. Radix always requires an array.
+
+**Incorrect (base):**
+
+```tsx
+<Slider defaultValue={[50]} max={100} step={1} />
+```
+
+**Correct (base):**
+
+```tsx
+<Slider defaultValue={50} max={100} step={1} />
+```
+
+**Correct (radix):**
+
+```tsx
+<Slider defaultValue={[50]} max={100} step={1} />
+```
+
+Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
+
+```tsx
+// base.
+const [value, setValue] = React.useState([0.3, 0.7])
+<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
+
+// radix.
+const [value, setValue] = React.useState([0.3, 0.7])
+<Slider value={value} onValueChange={setValue} />
+```
+
+---
+
+## Accordion
+
+Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
+
+**Incorrect (base):**
+
+```tsx
+<Accordion type="single" collapsible defaultValue="item-1">
+  <AccordionItem value="item-1">...</AccordionItem>
+</Accordion>
+```
+
+**Correct (base):**
+
+```tsx
+<Accordion defaultValue={["item-1"]}>
+  <AccordionItem value="item-1">...</AccordionItem>
+</Accordion>
+
+// Multi-select.
+<Accordion multiple defaultValue={["item-1", "item-2"]}>
+  <AccordionItem value="item-1">...</AccordionItem>
+  <AccordionItem value="item-2">...</AccordionItem>
+</Accordion>
+```
+
+**Correct (radix):**
+
+```tsx
+<Accordion type="single" collapsible defaultValue="item-1">
+  <AccordionItem value="item-1">...</AccordionItem>
+</Accordion>
+```

+ 195 - 0
.agents/skills/shadcn-ui/vendor/shadcn/rules/composition.md

@@ -0,0 +1,195 @@
+# Component Composition
+
+## Contents
+
+- Items always inside their Group component
+- Callouts use Alert
+- Empty states use Empty component
+- Toast notifications use sonner
+- Choosing between overlay components
+- Dialog, Sheet, and Drawer always need a Title
+- Card structure
+- Button has no isPending or isLoading prop
+- TabsTrigger must be inside TabsList
+- Avatar always needs AvatarFallback
+- Use Separator instead of raw hr or border divs
+- Use Skeleton for loading placeholders
+- Use Badge instead of custom styled spans
+
+---
+
+## Items always inside their Group component
+
+Never render items directly inside the content container.
+
+**Incorrect:**
+
+```tsx
+<SelectContent>
+  <SelectItem value="apple">Apple</SelectItem>
+  <SelectItem value="banana">Banana</SelectItem>
+</SelectContent>
+```
+
+**Correct:**
+
+```tsx
+<SelectContent>
+  <SelectGroup>
+    <SelectItem value="apple">Apple</SelectItem>
+    <SelectItem value="banana">Banana</SelectItem>
+  </SelectGroup>
+</SelectContent>
+```
+
+This applies to all group-based components:
+
+| Item | Group |
+|------|-------|
+| `SelectItem`, `SelectLabel` | `SelectGroup` |
+| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
+| `MenubarItem` | `MenubarGroup` |
+| `ContextMenuItem` | `ContextMenuGroup` |
+| `CommandItem` | `CommandGroup` |
+
+---
+
+## Callouts use Alert
+
+```tsx
+<Alert>
+  <AlertTitle>Warning</AlertTitle>
+  <AlertDescription>Something needs attention.</AlertDescription>
+</Alert>
+```
+
+---
+
+## Empty states use Empty component
+
+```tsx
+<Empty>
+  <EmptyHeader>
+    <EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
+    <EmptyTitle>No projects yet</EmptyTitle>
+    <EmptyDescription>Get started by creating a new project.</EmptyDescription>
+  </EmptyHeader>
+  <EmptyContent>
+    <Button>Create Project</Button>
+  </EmptyContent>
+</Empty>
+```
+
+---
+
+## Toast notifications use sonner
+
+```tsx
+import { toast } from "sonner"
+
+toast.success("Changes saved.")
+toast.error("Something went wrong.")
+toast("File deleted.", {
+  action: { label: "Undo", onClick: () => undoDelete() },
+})
+```
+
+---
+
+## Choosing between overlay components
+
+| Use case | Component |
+|----------|-----------|
+| Focused task that requires input | `Dialog` |
+| Destructive action confirmation | `AlertDialog` |
+| Side panel with details or filters | `Sheet` |
+| Mobile-first bottom panel | `Drawer` |
+| Quick info on hover | `HoverCard` |
+| Small contextual content on click | `Popover` |
+
+---
+
+## Dialog, Sheet, and Drawer always need a Title
+
+`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
+
+```tsx
+<DialogContent>
+  <DialogHeader>
+    <DialogTitle>Edit Profile</DialogTitle>
+    <DialogDescription>Update your profile.</DialogDescription>
+  </DialogHeader>
+  ...
+</DialogContent>
+```
+
+---
+
+## Card structure
+
+Use full composition — don't dump everything into `CardContent`:
+
+```tsx
+<Card>
+  <CardHeader>
+    <CardTitle>Team Members</CardTitle>
+    <CardDescription>Manage your team.</CardDescription>
+  </CardHeader>
+  <CardContent>...</CardContent>
+  <CardFooter>
+    <Button>Invite</Button>
+  </CardFooter>
+</Card>
+```
+
+---
+
+## Button has no isPending or isLoading prop
+
+Compose with `Spinner` + `data-icon` + `disabled`:
+
+```tsx
+<Button disabled>
+  <Spinner data-icon="inline-start" />
+  Saving...
+</Button>
+```
+
+---
+
+## TabsTrigger must be inside TabsList
+
+Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
+
+```tsx
+<Tabs defaultValue="account">
+  <TabsList>
+    <TabsTrigger value="account">Account</TabsTrigger>
+    <TabsTrigger value="password">Password</TabsTrigger>
+  </TabsList>
+  <TabsContent value="account">...</TabsContent>
+</Tabs>
+```
+
+---
+
+## Avatar always needs AvatarFallback
+
+Always include `AvatarFallback` for when the image fails to load:
+
+```tsx
+<Avatar>
+  <AvatarImage src="/avatar.png" alt="User" />
+  <AvatarFallback>JD</AvatarFallback>
+</Avatar>
+```
+
+---
+
+## Use existing components instead of custom markup
+
+| Instead of | Use |
+|---|---|
+| `<hr>` or `<div className="border-t">` | `<Separator />` |
+| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
+| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |

+ 192 - 0
.agents/skills/shadcn-ui/vendor/shadcn/rules/forms.md

@@ -0,0 +1,192 @@
+# Forms & Inputs
+
+## Contents
+
+- Forms use FieldGroup + Field
+- InputGroup requires InputGroupInput/InputGroupTextarea
+- Buttons inside inputs use InputGroup + InputGroupAddon
+- Option sets (2–7 choices) use ToggleGroup
+- FieldSet + FieldLegend for grouping related fields
+- Field validation and disabled states
+
+---
+
+## Forms use FieldGroup + Field
+
+Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
+
+```tsx
+<FieldGroup>
+  <Field>
+    <FieldLabel htmlFor="email">Email</FieldLabel>
+    <Input id="email" type="email" />
+  </Field>
+  <Field>
+    <FieldLabel htmlFor="password">Password</FieldLabel>
+    <Input id="password" type="password" />
+  </Field>
+</FieldGroup>
+```
+
+Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
+
+**Choosing form controls:**
+
+- Simple text input → `Input`
+- Dropdown with predefined options → `Select`
+- Searchable dropdown → `Combobox`
+- Native HTML select (no JS) → `native-select`
+- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
+- Single choice from few options → `RadioGroup`
+- Toggle between 2–5 options → `ToggleGroup` + `ToggleGroupItem`
+- OTP/verification code → `InputOTP`
+- Multi-line text → `Textarea`
+
+---
+
+## InputGroup requires InputGroupInput/InputGroupTextarea
+
+Never use raw `Input` or `Textarea` inside an `InputGroup`.
+
+**Incorrect:**
+
+```tsx
+<InputGroup>
+  <Input placeholder="Search..." />
+</InputGroup>
+```
+
+**Correct:**
+
+```tsx
+import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
+
+<InputGroup>
+  <InputGroupInput placeholder="Search..." />
+</InputGroup>
+```
+
+---
+
+## Buttons inside inputs use InputGroup + InputGroupAddon
+
+Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
+
+**Incorrect:**
+
+```tsx
+<div className="relative">
+  <Input placeholder="Search..." className="pr-10" />
+  <Button className="absolute right-0 top-0" size="icon">
+    <SearchIcon />
+  </Button>
+</div>
+```
+
+**Correct:**
+
+```tsx
+import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
+
+<InputGroup>
+  <InputGroupInput placeholder="Search..." />
+  <InputGroupAddon>
+    <Button size="icon">
+      <SearchIcon data-icon="inline-start" />
+    </Button>
+  </InputGroupAddon>
+</InputGroup>
+```
+
+---
+
+## Option sets (2–7 choices) use ToggleGroup
+
+Don't manually loop `Button` components with active state.
+
+**Incorrect:**
+
+```tsx
+const [selected, setSelected] = useState("daily")
+
+<div className="flex gap-2">
+  {["daily", "weekly", "monthly"].map((option) => (
+    <Button
+      key={option}
+      variant={selected === option ? "default" : "outline"}
+      onClick={() => setSelected(option)}
+    >
+      {option}
+    </Button>
+  ))}
+</div>
+```
+
+**Correct:**
+
+```tsx
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+
+<ToggleGroup spacing={2}>
+  <ToggleGroupItem value="daily">Daily</ToggleGroupItem>
+  <ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
+  <ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
+</ToggleGroup>
+```
+
+Combine with `Field` for labelled toggle groups:
+
+```tsx
+<Field orientation="horizontal">
+  <FieldTitle id="theme-label">Theme</FieldTitle>
+  <ToggleGroup aria-labelledby="theme-label" spacing={2}>
+    <ToggleGroupItem value="light">Light</ToggleGroupItem>
+    <ToggleGroupItem value="dark">Dark</ToggleGroupItem>
+    <ToggleGroupItem value="system">System</ToggleGroupItem>
+  </ToggleGroup>
+</Field>
+```
+
+> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
+
+---
+
+## FieldSet + FieldLegend for grouping related fields
+
+Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
+
+```tsx
+<FieldSet>
+  <FieldLegend variant="label">Preferences</FieldLegend>
+  <FieldDescription>Select all that apply.</FieldDescription>
+  <FieldGroup className="gap-3">
+    <Field orientation="horizontal">
+      <Checkbox id="dark" />
+      <FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
+    </Field>
+  </FieldGroup>
+</FieldSet>
+```
+
+---
+
+## Field validation and disabled states
+
+Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
+
+```tsx
+// Invalid.
+<Field data-invalid>
+  <FieldLabel htmlFor="email">Email</FieldLabel>
+  <Input id="email" aria-invalid />
+  <FieldDescription>Invalid email address.</FieldDescription>
+</Field>
+
+// Disabled.
+<Field data-disabled>
+  <FieldLabel htmlFor="email">Email</FieldLabel>
+  <Input id="email" disabled />
+</Field>
+```
+
+Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.

+ 101 - 0
.agents/skills/shadcn-ui/vendor/shadcn/rules/icons.md

@@ -0,0 +1,101 @@
+# Icons
+
+**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide` → `lucide-react`, `tabler` → `@tabler/icons-react`, etc. Never assume `lucide-react`.
+
+---
+
+## Icons in Button use data-icon attribute
+
+Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
+
+**Incorrect:**
+
+```tsx
+<Button>
+  <SearchIcon className="mr-2 size-4" />
+  Search
+</Button>
+```
+
+**Correct:**
+
+```tsx
+<Button>
+  <SearchIcon data-icon="inline-start"/>
+  Search
+</Button>
+
+<Button>
+  Next
+  <ArrowRightIcon data-icon="inline-end"/>
+</Button>
+```
+
+---
+
+## No sizing classes on icons inside components
+
+Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
+
+**Incorrect:**
+
+```tsx
+<Button>
+  <SearchIcon className="size-4" data-icon="inline-start" />
+  Search
+</Button>
+
+<DropdownMenuItem>
+  <SettingsIcon className="mr-2 size-4" />
+  Settings
+</DropdownMenuItem>
+```
+
+**Correct:**
+
+```tsx
+<Button>
+  <SearchIcon data-icon="inline-start" />
+  Search
+</Button>
+
+<DropdownMenuItem>
+  <SettingsIcon />
+  Settings
+</DropdownMenuItem>
+```
+
+---
+
+## Pass icons as component objects, not string keys
+
+Use `icon={CheckIcon}`, not a string key to a lookup map.
+
+**Incorrect:**
+
+```tsx
+const iconMap = {
+  check: CheckIcon,
+  alert: AlertIcon,
+}
+
+function StatusBadge({ icon }: { icon: string }) {
+  const Icon = iconMap[icon]
+  return <Icon />
+}
+
+<StatusBadge icon="check" />
+```
+
+**Correct:**
+
+```tsx
+// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
+import { CheckIcon } from "lucide-react"
+
+function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
+  return <Icon />
+}
+
+<StatusBadge icon={CheckIcon} />
+```

+ 162 - 0
.agents/skills/shadcn-ui/vendor/shadcn/rules/styling.md

@@ -0,0 +1,162 @@
+# Styling & Customization
+
+See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
+
+## Contents
+
+- Semantic colors
+- Built-in variants first
+- className for layout only
+- No space-x-* / space-y-*
+- Prefer size-* over w-* h-* when equal
+- Prefer truncate shorthand
+- No manual dark: color overrides
+- Use cn() for conditional classes
+- No manual z-index on overlay components
+
+---
+
+## Semantic colors
+
+**Incorrect:**
+
+```tsx
+<div className="bg-blue-500 text-white">
+  <p className="text-gray-600">Secondary text</p>
+</div>
+```
+
+**Correct:**
+
+```tsx
+<div className="bg-primary text-primary-foreground">
+  <p className="text-muted-foreground">Secondary text</p>
+</div>
+```
+
+---
+
+## No raw color values for status/state indicators
+
+For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
+
+**Incorrect:**
+
+```tsx
+<span className="text-emerald-600">+20.1%</span>
+<span className="text-green-500">Active</span>
+<span className="text-red-600">-3.2%</span>
+```
+
+**Correct:**
+
+```tsx
+<Badge variant="secondary">+20.1%</Badge>
+<Badge>Active</Badge>
+<span className="text-destructive">-3.2%</span>
+```
+
+If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
+
+---
+
+## Built-in variants first
+
+**Incorrect:**
+
+```tsx
+<Button className="border border-input bg-transparent hover:bg-accent">
+  Click me
+</Button>
+```
+
+**Correct:**
+
+```tsx
+<Button variant="outline">Click me</Button>
+```
+
+---
+
+## className for layout only
+
+Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
+
+**Incorrect:**
+
+```tsx
+<Card className="bg-blue-100 text-blue-900 font-bold">
+  <CardContent>Dashboard</CardContent>
+</Card>
+```
+
+**Correct:**
+
+```tsx
+<Card className="max-w-md mx-auto">
+  <CardContent>Dashboard</CardContent>
+</Card>
+```
+
+To customize a component's appearance, prefer these approaches in order:
+1. **Built-in variants** — `variant="outline"`, `variant="destructive"`, etc.
+2. **Semantic color tokens** — `bg-primary`, `text-muted-foreground`.
+3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
+
+---
+
+## No space-x-* / space-y-*
+
+Use `gap-*` instead. `space-y-4` → `flex flex-col gap-4`. `space-x-2` → `flex gap-2`.
+
+```tsx
+<div className="flex flex-col gap-4">
+  <Input />
+  <Input />
+  <Button>Submit</Button>
+</div>
+```
+
+---
+
+## Prefer size-* over w-* h-* when equal
+
+`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
+
+---
+
+## Prefer truncate shorthand
+
+`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
+
+---
+
+## No manual dark: color overrides
+
+Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
+
+---
+
+## Use cn() for conditional classes
+
+Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings.
+
+**Incorrect:**
+
+```tsx
+<div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
+```
+
+**Correct:**
+
+```tsx
+import { cn } from "@/lib/utils"
+
+<div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
+```
+
+---
+
+## No manual z-index on overlay components
+
+`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.

+ 2934 - 0
.agents/skills/vercel-react-best-practices/AGENTS.md

@@ -0,0 +1,2934 @@
+# React Best Practices
+
+**Version 1.0.0**  
+Vercel Engineering  
+January 2026
+
+> **Note:**  
+> This document is mainly for agents and LLMs to follow when maintaining,  
+> generating, or refactoring React and Next.js codebases. Humans  
+> may also find it useful, but guidance here is optimized for automation  
+> and consistency by AI-assisted workflows.
+
+---
+
+## Abstract
+
+Comprehensive performance optimization guide for React and Next.js applications, designed for AI agents and LLMs. Contains 40+ rules across 8 categories, prioritized by impact from critical (eliminating waterfalls, reducing bundle size) to incremental (advanced patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
+
+---
+
+## Table of Contents
+
+1. [Eliminating Waterfalls](#1-eliminating-waterfalls) — **CRITICAL**
+   - 1.1 [Defer Await Until Needed](#11-defer-await-until-needed)
+   - 1.2 [Dependency-Based Parallelization](#12-dependency-based-parallelization)
+   - 1.3 [Prevent Waterfall Chains in API Routes](#13-prevent-waterfall-chains-in-api-routes)
+   - 1.4 [Promise.all() for Independent Operations](#14-promiseall-for-independent-operations)
+   - 1.5 [Strategic Suspense Boundaries](#15-strategic-suspense-boundaries)
+2. [Bundle Size Optimization](#2-bundle-size-optimization) — **CRITICAL**
+   - 2.1 [Avoid Barrel File Imports](#21-avoid-barrel-file-imports)
+   - 2.2 [Conditional Module Loading](#22-conditional-module-loading)
+   - 2.3 [Defer Non-Critical Third-Party Libraries](#23-defer-non-critical-third-party-libraries)
+   - 2.4 [Dynamic Imports for Heavy Components](#24-dynamic-imports-for-heavy-components)
+   - 2.5 [Preload Based on User Intent](#25-preload-based-on-user-intent)
+3. [Server-Side Performance](#3-server-side-performance) — **HIGH**
+   - 3.1 [Authenticate Server Actions Like API Routes](#31-authenticate-server-actions-like-api-routes)
+   - 3.2 [Avoid Duplicate Serialization in RSC Props](#32-avoid-duplicate-serialization-in-rsc-props)
+   - 3.3 [Cross-Request LRU Caching](#33-cross-request-lru-caching)
+   - 3.4 [Minimize Serialization at RSC Boundaries](#34-minimize-serialization-at-rsc-boundaries)
+   - 3.5 [Parallel Data Fetching with Component Composition](#35-parallel-data-fetching-with-component-composition)
+   - 3.6 [Per-Request Deduplication with React.cache()](#36-per-request-deduplication-with-reactcache)
+   - 3.7 [Use after() for Non-Blocking Operations](#37-use-after-for-non-blocking-operations)
+4. [Client-Side Data Fetching](#4-client-side-data-fetching) — **MEDIUM-HIGH**
+   - 4.1 [Deduplicate Global Event Listeners](#41-deduplicate-global-event-listeners)
+   - 4.2 [Use Passive Event Listeners for Scrolling Performance](#42-use-passive-event-listeners-for-scrolling-performance)
+   - 4.3 [Use SWR for Automatic Deduplication](#43-use-swr-for-automatic-deduplication)
+   - 4.4 [Version and Minimize localStorage Data](#44-version-and-minimize-localstorage-data)
+5. [Re-render Optimization](#5-re-render-optimization) — **MEDIUM**
+   - 5.1 [Calculate Derived State During Rendering](#51-calculate-derived-state-during-rendering)
+   - 5.2 [Defer State Reads to Usage Point](#52-defer-state-reads-to-usage-point)
+   - 5.3 [Do not wrap a simple expression with a primitive result type in useMemo](#53-do-not-wrap-a-simple-expression-with-a-primitive-result-type-in-usememo)
+   - 5.4 [Extract Default Non-primitive Parameter Value from Memoized Component to Constant](#54-extract-default-non-primitive-parameter-value-from-memoized-component-to-constant)
+   - 5.5 [Extract to Memoized Components](#55-extract-to-memoized-components)
+   - 5.6 [Narrow Effect Dependencies](#56-narrow-effect-dependencies)
+   - 5.7 [Put Interaction Logic in Event Handlers](#57-put-interaction-logic-in-event-handlers)
+   - 5.8 [Subscribe to Derived State](#58-subscribe-to-derived-state)
+   - 5.9 [Use Functional setState Updates](#59-use-functional-setstate-updates)
+   - 5.10 [Use Lazy State Initialization](#510-use-lazy-state-initialization)
+   - 5.11 [Use Transitions for Non-Urgent Updates](#511-use-transitions-for-non-urgent-updates)
+   - 5.12 [Use useRef for Transient Values](#512-use-useref-for-transient-values)
+6. [Rendering Performance](#6-rendering-performance) — **MEDIUM**
+   - 6.1 [Animate SVG Wrapper Instead of SVG Element](#61-animate-svg-wrapper-instead-of-svg-element)
+   - 6.2 [CSS content-visibility for Long Lists](#62-css-content-visibility-for-long-lists)
+   - 6.3 [Hoist Static JSX Elements](#63-hoist-static-jsx-elements)
+   - 6.4 [Optimize SVG Precision](#64-optimize-svg-precision)
+   - 6.5 [Prevent Hydration Mismatch Without Flickering](#65-prevent-hydration-mismatch-without-flickering)
+   - 6.6 [Suppress Expected Hydration Mismatches](#66-suppress-expected-hydration-mismatches)
+   - 6.7 [Use Activity Component for Show/Hide](#67-use-activity-component-for-showhide)
+   - 6.8 [Use Explicit Conditional Rendering](#68-use-explicit-conditional-rendering)
+   - 6.9 [Use useTransition Over Manual Loading States](#69-use-usetransition-over-manual-loading-states)
+7. [JavaScript Performance](#7-javascript-performance) — **LOW-MEDIUM**
+   - 7.1 [Avoid Layout Thrashing](#71-avoid-layout-thrashing)
+   - 7.2 [Build Index Maps for Repeated Lookups](#72-build-index-maps-for-repeated-lookups)
+   - 7.3 [Cache Property Access in Loops](#73-cache-property-access-in-loops)
+   - 7.4 [Cache Repeated Function Calls](#74-cache-repeated-function-calls)
+   - 7.5 [Cache Storage API Calls](#75-cache-storage-api-calls)
+   - 7.6 [Combine Multiple Array Iterations](#76-combine-multiple-array-iterations)
+   - 7.7 [Early Length Check for Array Comparisons](#77-early-length-check-for-array-comparisons)
+   - 7.8 [Early Return from Functions](#78-early-return-from-functions)
+   - 7.9 [Hoist RegExp Creation](#79-hoist-regexp-creation)
+   - 7.10 [Use Loop for Min/Max Instead of Sort](#710-use-loop-for-minmax-instead-of-sort)
+   - 7.11 [Use Set/Map for O(1) Lookups](#711-use-setmap-for-o1-lookups)
+   - 7.12 [Use toSorted() Instead of sort() for Immutability](#712-use-tosorted-instead-of-sort-for-immutability)
+8. [Advanced Patterns](#8-advanced-patterns) — **LOW**
+   - 8.1 [Initialize App Once, Not Per Mount](#81-initialize-app-once-not-per-mount)
+   - 8.2 [Store Event Handlers in Refs](#82-store-event-handlers-in-refs)
+   - 8.3 [useEffectEvent for Stable Callback Refs](#83-useeffectevent-for-stable-callback-refs)
+
+---
+
+## 1. Eliminating Waterfalls
+
+**Impact: CRITICAL**
+
+Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains.
+
+### 1.1 Defer Await Until Needed
+
+**Impact: HIGH (avoids blocking unused code paths)**
+
+Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them.
+
+**Incorrect: blocks both branches**
+
+```typescript
+async function handleRequest(userId: string, skipProcessing: boolean) {
+  const userData = await fetchUserData(userId)
+  
+  if (skipProcessing) {
+    // Returns immediately but still waited for userData
+    return { skipped: true }
+  }
+  
+  // Only this branch uses userData
+  return processUserData(userData)
+}
+```
+
+**Correct: only blocks when needed**
+
+```typescript
+async function handleRequest(userId: string, skipProcessing: boolean) {
+  if (skipProcessing) {
+    // Returns immediately without waiting
+    return { skipped: true }
+  }
+  
+  // Fetch only when needed
+  const userData = await fetchUserData(userId)
+  return processUserData(userData)
+}
+```
+
+**Another example: early return optimization**
+
+```typescript
+// Incorrect: always fetches permissions
+async function updateResource(resourceId: string, userId: string) {
+  const permissions = await fetchPermissions(userId)
+  const resource = await getResource(resourceId)
+  
+  if (!resource) {
+    return { error: 'Not found' }
+  }
+  
+  if (!permissions.canEdit) {
+    return { error: 'Forbidden' }
+  }
+  
+  return await updateResourceData(resource, permissions)
+}
+
+// Correct: fetches only when needed
+async function updateResource(resourceId: string, userId: string) {
+  const resource = await getResource(resourceId)
+  
+  if (!resource) {
+    return { error: 'Not found' }
+  }
+  
+  const permissions = await fetchPermissions(userId)
+  
+  if (!permissions.canEdit) {
+    return { error: 'Forbidden' }
+  }
+  
+  return await updateResourceData(resource, permissions)
+}
+```
+
+This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive.
+
+### 1.2 Dependency-Based Parallelization
+
+**Impact: CRITICAL (2-10× improvement)**
+
+For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment.
+
+**Incorrect: profile waits for config unnecessarily**
+
+```typescript
+const [user, config] = await Promise.all([
+  fetchUser(),
+  fetchConfig()
+])
+const profile = await fetchProfile(user.id)
+```
+
+**Correct: config and profile run in parallel**
+
+```typescript
+import { all } from 'better-all'
+
+const { user, config, profile } = await all({
+  async user() { return fetchUser() },
+  async config() { return fetchConfig() },
+  async profile() {
+    return fetchProfile((await this.$.user).id)
+  }
+})
+```
+
+**Alternative without extra dependencies:**
+
+```typescript
+const userPromise = fetchUser()
+const profilePromise = userPromise.then(user => fetchProfile(user.id))
+
+const [user, config, profile] = await Promise.all([
+  userPromise,
+  fetchConfig(),
+  profilePromise
+])
+```
+
+We can also create all the promises first, and do `Promise.all()` at the end.
+
+Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
+
+### 1.3 Prevent Waterfall Chains in API Routes
+
+**Impact: CRITICAL (2-10× improvement)**
+
+In API routes and Server Actions, start independent operations immediately, even if you don't await them yet.
+
+**Incorrect: config waits for auth, data waits for both**
+
+```typescript
+export async function GET(request: Request) {
+  const session = await auth()
+  const config = await fetchConfig()
+  const data = await fetchData(session.user.id)
+  return Response.json({ data, config })
+}
+```
+
+**Correct: auth and config start immediately**
+
+```typescript
+export async function GET(request: Request) {
+  const sessionPromise = auth()
+  const configPromise = fetchConfig()
+  const session = await sessionPromise
+  const [config, data] = await Promise.all([
+    configPromise,
+    fetchData(session.user.id)
+  ])
+  return Response.json({ data, config })
+}
+```
+
+For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization).
+
+### 1.4 Promise.all() for Independent Operations
+
+**Impact: CRITICAL (2-10× improvement)**
+
+When async operations have no interdependencies, execute them concurrently using `Promise.all()`.
+
+**Incorrect: sequential execution, 3 round trips**
+
+```typescript
+const user = await fetchUser()
+const posts = await fetchPosts()
+const comments = await fetchComments()
+```
+
+**Correct: parallel execution, 1 round trip**
+
+```typescript
+const [user, posts, comments] = await Promise.all([
+  fetchUser(),
+  fetchPosts(),
+  fetchComments()
+])
+```
+
+### 1.5 Strategic Suspense Boundaries
+
+**Impact: HIGH (faster initial paint)**
+
+Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads.
+
+**Incorrect: wrapper blocked by data fetching**
+
+```tsx
+async function Page() {
+  const data = await fetchData() // Blocks entire page
+  
+  return (
+    <div>
+      <div>Sidebar</div>
+      <div>Header</div>
+      <div>
+        <DataDisplay data={data} />
+      </div>
+      <div>Footer</div>
+    </div>
+  )
+}
+```
+
+The entire layout waits for data even though only the middle section needs it.
+
+**Correct: wrapper shows immediately, data streams in**
+
+```tsx
+function Page() {
+  return (
+    <div>
+      <div>Sidebar</div>
+      <div>Header</div>
+      <div>
+        <Suspense fallback={<Skeleton />}>
+          <DataDisplay />
+        </Suspense>
+      </div>
+      <div>Footer</div>
+    </div>
+  )
+}
+
+async function DataDisplay() {
+  const data = await fetchData() // Only blocks this component
+  return <div>{data.content}</div>
+}
+```
+
+Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data.
+
+**Alternative: share promise across components**
+
+```tsx
+function Page() {
+  // Start fetch immediately, but don't await
+  const dataPromise = fetchData()
+  
+  return (
+    <div>
+      <div>Sidebar</div>
+      <div>Header</div>
+      <Suspense fallback={<Skeleton />}>
+        <DataDisplay dataPromise={dataPromise} />
+        <DataSummary dataPromise={dataPromise} />
+      </Suspense>
+      <div>Footer</div>
+    </div>
+  )
+}
+
+function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
+  const data = use(dataPromise) // Unwraps the promise
+  return <div>{data.content}</div>
+}
+
+function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
+  const data = use(dataPromise) // Reuses the same promise
+  return <div>{data.summary}</div>
+}
+```
+
+Both components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together.
+
+**When NOT to use this pattern:**
+
+- Critical data needed for layout decisions (affects positioning)
+
+- SEO-critical content above the fold
+
+- Small, fast queries where suspense overhead isn't worth it
+
+- When you want to avoid layout shift (loading → content jump)
+
+**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities.
+
+---
+
+## 2. Bundle Size Optimization
+
+**Impact: CRITICAL**
+
+Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint.
+
+### 2.1 Avoid Barrel File Imports
+
+**Impact: CRITICAL (200-800ms import cost, slow builds)**
+
+Import directly from source files instead of barrel files to avoid loading thousands of unused modules. **Barrel files** are entry points that re-export multiple modules (e.g., `index.js` that does `export * from './module'`).
+
+Popular icon and component libraries can have **up to 10,000 re-exports** in their entry file. For many React packages, **it takes 200-800ms just to import them**, affecting both development speed and production cold starts.
+
+**Why tree-shaking doesn't help:** When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph.
+
+**Incorrect: imports entire library**
+
+```tsx
+import { Check, X, Menu } from 'lucide-react'
+// Loads 1,583 modules, takes ~2.8s extra in dev
+// Runtime cost: 200-800ms on every cold start
+
+import { Button, TextField } from '@mui/material'
+// Loads 2,225 modules, takes ~4.2s extra in dev
+```
+
+**Correct: imports only what you need**
+
+```tsx
+import Check from 'lucide-react/dist/esm/icons/check'
+import X from 'lucide-react/dist/esm/icons/x'
+import Menu from 'lucide-react/dist/esm/icons/menu'
+// Loads only 3 modules (~2KB vs ~1MB)
+
+import Button from '@mui/material/Button'
+import TextField from '@mui/material/TextField'
+// Loads only what you use
+```
+
+**Alternative: Next.js 13.5+**
+
+```js
+// next.config.js - use optimizePackageImports
+module.exports = {
+  experimental: {
+    optimizePackageImports: ['lucide-react', '@mui/material']
+  }
+}
+
+// Then you can keep the ergonomic barrel imports:
+import { Check, X, Menu } from 'lucide-react'
+// Automatically transformed to direct imports at build time
+```
+
+Direct imports provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR.
+
+Libraries commonly affected: `lucide-react`, `@mui/material`, `@mui/icons-material`, `@tabler/icons-react`, `react-icons`, `@headlessui/react`, `@radix-ui/react-*`, `lodash`, `ramda`, `date-fns`, `rxjs`, `react-use`.
+
+Reference: [https://vercel.com/blog/how-we-optimized-package-imports-in-next-js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
+
+### 2.2 Conditional Module Loading
+
+**Impact: HIGH (loads large data only when needed)**
+
+Load large data or modules only when a feature is activated.
+
+**Example: lazy-load animation frames**
+
+```tsx
+function AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch<React.SetStateAction<boolean>> }) {
+  const [frames, setFrames] = useState<Frame[] | null>(null)
+
+  useEffect(() => {
+    if (enabled && !frames && typeof window !== 'undefined') {
+      import('./animation-frames.js')
+        .then(mod => setFrames(mod.frames))
+        .catch(() => setEnabled(false))
+    }
+  }, [enabled, frames, setEnabled])
+
+  if (!frames) return <Skeleton />
+  return <Canvas frames={frames} />
+}
+```
+
+The `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed.
+
+### 2.3 Defer Non-Critical Third-Party Libraries
+
+**Impact: MEDIUM (loads after hydration)**
+
+Analytics, logging, and error tracking don't block user interaction. Load them after hydration.
+
+**Incorrect: blocks initial bundle**
+
+```tsx
+import { Analytics } from '@vercel/analytics/react'
+
+export default function RootLayout({ children }) {
+  return (
+    <html>
+      <body>
+        {children}
+        <Analytics />
+      </body>
+    </html>
+  )
+}
+```
+
+**Correct: loads after hydration**
+
+```tsx
+import dynamic from 'next/dynamic'
+
+const Analytics = dynamic(
+  () => import('@vercel/analytics/react').then(m => m.Analytics),
+  { ssr: false }
+)
+
+export default function RootLayout({ children }) {
+  return (
+    <html>
+      <body>
+        {children}
+        <Analytics />
+      </body>
+    </html>
+  )
+}
+```
+
+### 2.4 Dynamic Imports for Heavy Components
+
+**Impact: CRITICAL (directly affects TTI and LCP)**
+
+Use `next/dynamic` to lazy-load large components not needed on initial render.
+
+**Incorrect: Monaco bundles with main chunk ~300KB**
+
+```tsx
+import { MonacoEditor } from './monaco-editor'
+
+function CodePanel({ code }: { code: string }) {
+  return <MonacoEditor value={code} />
+}
+```
+
+**Correct: Monaco loads on demand**
+
+```tsx
+import dynamic from 'next/dynamic'
+
+const MonacoEditor = dynamic(
+  () => import('./monaco-editor').then(m => m.MonacoEditor),
+  { ssr: false }
+)
+
+function CodePanel({ code }: { code: string }) {
+  return <MonacoEditor value={code} />
+}
+```
+
+### 2.5 Preload Based on User Intent
+
+**Impact: MEDIUM (reduces perceived latency)**
+
+Preload heavy bundles before they're needed to reduce perceived latency.
+
+**Example: preload on hover/focus**
+
+```tsx
+function EditorButton({ onClick }: { onClick: () => void }) {
+  const preload = () => {
+    if (typeof window !== 'undefined') {
+      void import('./monaco-editor')
+    }
+  }
+
+  return (
+    <button
+      onMouseEnter={preload}
+      onFocus={preload}
+      onClick={onClick}
+    >
+      Open Editor
+    </button>
+  )
+}
+```
+
+**Example: preload when feature flag is enabled**
+
+```tsx
+function FlagsProvider({ children, flags }: Props) {
+  useEffect(() => {
+    if (flags.editorEnabled && typeof window !== 'undefined') {
+      void import('./monaco-editor').then(mod => mod.init())
+    }
+  }, [flags.editorEnabled])
+
+  return <FlagsContext.Provider value={flags}>
+    {children}
+  </FlagsContext.Provider>
+}
+```
+
+The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed.
+
+---
+
+## 3. Server-Side Performance
+
+**Impact: HIGH**
+
+Optimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times.
+
+### 3.1 Authenticate Server Actions Like API Routes
+
+**Impact: CRITICAL (prevents unauthorized access to server mutations)**
+
+Server Actions (functions with `"use server"`) are exposed as public endpoints, just like API routes. Always verify authentication and authorization **inside** each Server Action—do not rely solely on middleware, layout guards, or page-level checks, as Server Actions can be invoked directly.
+
+Next.js documentation explicitly states: "Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation."
+
+**Incorrect: no authentication check**
+
+```typescript
+'use server'
+
+export async function deleteUser(userId: string) {
+  // Anyone can call this! No auth check
+  await db.user.delete({ where: { id: userId } })
+  return { success: true }
+}
+```
+
+**Correct: authentication inside the action**
+
+```typescript
+'use server'
+
+import { verifySession } from '@/lib/auth'
+import { unauthorized } from '@/lib/errors'
+
+export async function deleteUser(userId: string) {
+  // Always check auth inside the action
+  const session = await verifySession()
+  
+  if (!session) {
+    throw unauthorized('Must be logged in')
+  }
+  
+  // Check authorization too
+  if (session.user.role !== 'admin' && session.user.id !== userId) {
+    throw unauthorized('Cannot delete other users')
+  }
+  
+  await db.user.delete({ where: { id: userId } })
+  return { success: true }
+}
+```
+
+**With input validation:**
+
+```typescript
+'use server'
+
+import { verifySession } from '@/lib/auth'
+import { z } from 'zod'
+
+const updateProfileSchema = z.object({
+  userId: z.string().uuid(),
+  name: z.string().min(1).max(100),
+  email: z.string().email()
+})
+
+export async function updateProfile(data: unknown) {
+  // Validate input first
+  const validated = updateProfileSchema.parse(data)
+  
+  // Then authenticate
+  const session = await verifySession()
+  if (!session) {
+    throw new Error('Unauthorized')
+  }
+  
+  // Then authorize
+  if (session.user.id !== validated.userId) {
+    throw new Error('Can only update own profile')
+  }
+  
+  // Finally perform the mutation
+  await db.user.update({
+    where: { id: validated.userId },
+    data: {
+      name: validated.name,
+      email: validated.email
+    }
+  })
+  
+  return { success: true }
+}
+```
+
+Reference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication)
+
+### 3.2 Avoid Duplicate Serialization in RSC Props
+
+**Impact: LOW (reduces network payload by avoiding duplicate serialization)**
+
+RSC→client serialization deduplicates by object reference, not value. Same reference = serialized once; new reference = serialized again. Do transformations (`.toSorted()`, `.filter()`, `.map()`) in client, not server.
+
+**Incorrect: duplicates array**
+
+```tsx
+// RSC: sends 6 strings (2 arrays × 3 items)
+<ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} />
+```
+
+**Correct: sends 3 strings**
+
+```tsx
+// RSC: send once
+<ClientList usernames={usernames} />
+
+// Client: transform there
+'use client'
+const sorted = useMemo(() => [...usernames].sort(), [usernames])
+```
+
+**Nested deduplication behavior:**
+
+```tsx
+// string[] - duplicates everything
+usernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings
+
+// object[] - duplicates array structure only
+users={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4)
+```
+
+Deduplication works recursively. Impact varies by data type:
+
+- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated
+
+- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference
+
+**Operations breaking deduplication: create new references**
+
+- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]`
+
+- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())`
+
+**More examples:**
+
+```tsx
+// ❌ Bad
+<C users={users} active={users.filter(u => u.active)} />
+<C product={product} productName={product.name} />
+
+// ✅ Good
+<C users={users} />
+<C product={product} />
+// Do filtering/destructuring in client
+```
+
+**Exception:** Pass derived data when transformation is expensive or client doesn't need original.
+
+### 3.3 Cross-Request LRU Caching
+
+**Impact: HIGH (caches across requests)**
+
+`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.
+
+**Implementation:**
+
+```typescript
+import { LRUCache } from 'lru-cache'
+
+const cache = new LRUCache<string, any>({
+  max: 1000,
+  ttl: 5 * 60 * 1000  // 5 minutes
+})
+
+export async function getUser(id: string) {
+  const cached = cache.get(id)
+  if (cached) return cached
+
+  const user = await db.user.findUnique({ where: { id } })
+  cache.set(id, user)
+  return user
+}
+
+// Request 1: DB query, result cached
+// Request 2: cache hit, no DB query
+```
+
+Use when sequential user actions hit multiple endpoints needing the same data within seconds.
+
+**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** LRU caching is especially effective because multiple concurrent requests can share the same function instance and cache. This means the cache persists across requests without needing external storage like Redis.
+
+**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching.
+
+Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
+
+### 3.4 Minimize Serialization at RSC Boundaries
+
+**Impact: HIGH (reduces data transfer size)**
+
+The React Server/Client boundary serializes all object properties into strings and embeds them in the HTML response and subsequent RSC requests. This serialized data directly impacts page weight and load time, so **size matters a lot**. Only pass fields that the client actually uses.
+
+**Incorrect: serializes all 50 fields**
+
+```tsx
+async function Page() {
+  const user = await fetchUser()  // 50 fields
+  return <Profile user={user} />
+}
+
+'use client'
+function Profile({ user }: { user: User }) {
+  return <div>{user.name}</div>  // uses 1 field
+}
+```
+
+**Correct: serializes only 1 field**
+
+```tsx
+async function Page() {
+  const user = await fetchUser()
+  return <Profile name={user.name} />
+}
+
+'use client'
+function Profile({ name }: { name: string }) {
+  return <div>{name}</div>
+}
+```
+
+### 3.5 Parallel Data Fetching with Component Composition
+
+**Impact: CRITICAL (eliminates server-side waterfalls)**
+
+React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.
+
+**Incorrect: Sidebar waits for Page's fetch to complete**
+
+```tsx
+export default async function Page() {
+  const header = await fetchHeader()
+  return (
+    <div>
+      <div>{header}</div>
+      <Sidebar />
+    </div>
+  )
+}
+
+async function Sidebar() {
+  const items = await fetchSidebarItems()
+  return <nav>{items.map(renderItem)}</nav>
+}
+```
+
+**Correct: both fetch simultaneously**
+
+```tsx
+async function Header() {
+  const data = await fetchHeader()
+  return <div>{data}</div>
+}
+
+async function Sidebar() {
+  const items = await fetchSidebarItems()
+  return <nav>{items.map(renderItem)}</nav>
+}
+
+export default function Page() {
+  return (
+    <div>
+      <Header />
+      <Sidebar />
+    </div>
+  )
+}
+```
+
+**Alternative with children prop:**
+
+```tsx
+async function Header() {
+  const data = await fetchHeader()
+  return <div>{data}</div>
+}
+
+async function Sidebar() {
+  const items = await fetchSidebarItems()
+  return <nav>{items.map(renderItem)}</nav>
+}
+
+function Layout({ children }: { children: ReactNode }) {
+  return (
+    <div>
+      <Header />
+      {children}
+    </div>
+  )
+}
+
+export default function Page() {
+  return (
+    <Layout>
+      <Sidebar />
+    </Layout>
+  )
+}
+```
+
+### 3.6 Per-Request Deduplication with React.cache()
+
+**Impact: MEDIUM (deduplicates within request)**
+
+Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most.
+
+**Usage:**
+
+```typescript
+import { cache } from 'react'
+
+export const getCurrentUser = cache(async () => {
+  const session = await auth()
+  if (!session?.user?.id) return null
+  return await db.user.findUnique({
+    where: { id: session.user.id }
+  })
+})
+```
+
+Within a single request, multiple calls to `getCurrentUser()` execute the query only once.
+
+**Avoid inline objects as arguments:**
+
+`React.cache()` uses shallow equality (`Object.is`) to determine cache hits. Inline objects create new references each call, preventing cache hits.
+
+**Incorrect: always cache miss**
+
+```typescript
+const getUser = cache(async (params: { uid: number }) => {
+  return await db.user.findUnique({ where: { id: params.uid } })
+})
+
+// Each call creates new object, never hits cache
+getUser({ uid: 1 })
+getUser({ uid: 1 })  // Cache miss, runs query again
+```
+
+**Correct: cache hit**
+
+```typescript
+const params = { uid: 1 }
+getUser(params)  // Query runs
+getUser(params)  // Cache hit (same reference)
+```
+
+If you must pass objects, pass the same reference:
+
+**Next.js-Specific Note:**
+
+In Next.js, the `fetch` API is automatically extended with request memoization. Requests with the same URL and options are automatically deduplicated within a single request, so you don't need `React.cache()` for `fetch` calls. However, `React.cache()` is still essential for other async tasks:
+
+- Database queries (Prisma, Drizzle, etc.)
+
+- Heavy computations
+
+- Authentication checks
+
+- File system operations
+
+- Any non-fetch async work
+
+Use `React.cache()` to deduplicate these operations across your component tree.
+
+Reference: [https://react.dev/reference/react/cache](https://react.dev/reference/react/cache)
+
+### 3.7 Use after() for Non-Blocking Operations
+
+**Impact: MEDIUM (faster response times)**
+
+Use Next.js's `after()` to schedule work that should execute after a response is sent. This prevents logging, analytics, and other side effects from blocking the response.
+
+**Incorrect: blocks response**
+
+```tsx
+import { logUserAction } from '@/app/utils'
+
+export async function POST(request: Request) {
+  // Perform mutation
+  await updateDatabase(request)
+  
+  // Logging blocks the response
+  const userAgent = request.headers.get('user-agent') || 'unknown'
+  await logUserAction({ userAgent })
+  
+  return new Response(JSON.stringify({ status: 'success' }), {
+    status: 200,
+    headers: { 'Content-Type': 'application/json' }
+  })
+}
+```
+
+**Correct: non-blocking**
+
+```tsx
+import { after } from 'next/server'
+import { headers, cookies } from 'next/headers'
+import { logUserAction } from '@/app/utils'
+
+export async function POST(request: Request) {
+  // Perform mutation
+  await updateDatabase(request)
+  
+  // Log after response is sent
+  after(async () => {
+    const userAgent = (await headers()).get('user-agent') || 'unknown'
+    const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'
+    
+    logUserAction({ sessionCookie, userAgent })
+  })
+  
+  return new Response(JSON.stringify({ status: 'success' }), {
+    status: 200,
+    headers: { 'Content-Type': 'application/json' }
+  })
+}
+```
+
+The response is sent immediately while logging happens in the background.
+
+**Common use cases:**
+
+- Analytics tracking
+
+- Audit logging
+
+- Sending notifications
+
+- Cache invalidation
+
+- Cleanup tasks
+
+**Important notes:**
+
+- `after()` runs even if the response fails or redirects
+
+- Works in Server Actions, Route Handlers, and Server Components
+
+Reference: [https://nextjs.org/docs/app/api-reference/functions/after](https://nextjs.org/docs/app/api-reference/functions/after)
+
+---
+
+## 4. Client-Side Data Fetching
+
+**Impact: MEDIUM-HIGH**
+
+Automatic deduplication and efficient data fetching patterns reduce redundant network requests.
+
+### 4.1 Deduplicate Global Event Listeners
+
+**Impact: LOW (single listener for N components)**
+
+Use `useSWRSubscription()` to share global event listeners across component instances.
+
+**Incorrect: N instances = N listeners**
+
+```tsx
+function useKeyboardShortcut(key: string, callback: () => void) {
+  useEffect(() => {
+    const handler = (e: KeyboardEvent) => {
+      if (e.metaKey && e.key === key) {
+        callback()
+      }
+    }
+    window.addEventListener('keydown', handler)
+    return () => window.removeEventListener('keydown', handler)
+  }, [key, callback])
+}
+```
+
+When using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener.
+
+**Correct: N instances = 1 listener**
+
+```tsx
+import useSWRSubscription from 'swr/subscription'
+
+// Module-level Map to track callbacks per key
+const keyCallbacks = new Map<string, Set<() => void>>()
+
+function useKeyboardShortcut(key: string, callback: () => void) {
+  // Register this callback in the Map
+  useEffect(() => {
+    if (!keyCallbacks.has(key)) {
+      keyCallbacks.set(key, new Set())
+    }
+    keyCallbacks.get(key)!.add(callback)
+
+    return () => {
+      const set = keyCallbacks.get(key)
+      if (set) {
+        set.delete(callback)
+        if (set.size === 0) {
+          keyCallbacks.delete(key)
+        }
+      }
+    }
+  }, [key, callback])
+
+  useSWRSubscription('global-keydown', () => {
+    const handler = (e: KeyboardEvent) => {
+      if (e.metaKey && keyCallbacks.has(e.key)) {
+        keyCallbacks.get(e.key)!.forEach(cb => cb())
+      }
+    }
+    window.addEventListener('keydown', handler)
+    return () => window.removeEventListener('keydown', handler)
+  })
+}
+
+function Profile() {
+  // Multiple shortcuts will share the same listener
+  useKeyboardShortcut('p', () => { /* ... */ }) 
+  useKeyboardShortcut('k', () => { /* ... */ })
+  // ...
+}
+```
+
+### 4.2 Use Passive Event Listeners for Scrolling Performance
+
+**Impact: MEDIUM (eliminates scroll delay caused by event listeners)**
+
+Add `{ passive: true }` to touch and wheel event listeners to enable immediate scrolling. Browsers normally wait for listeners to finish to check if `preventDefault()` is called, causing scroll delay.
+
+**Incorrect:**
+
+```typescript
+useEffect(() => {
+  const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
+  const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
+  
+  document.addEventListener('touchstart', handleTouch)
+  document.addEventListener('wheel', handleWheel)
+  
+  return () => {
+    document.removeEventListener('touchstart', handleTouch)
+    document.removeEventListener('wheel', handleWheel)
+  }
+}, [])
+```
+
+**Correct:**
+
+```typescript
+useEffect(() => {
+  const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
+  const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
+  
+  document.addEventListener('touchstart', handleTouch, { passive: true })
+  document.addEventListener('wheel', handleWheel, { passive: true })
+  
+  return () => {
+    document.removeEventListener('touchstart', handleTouch)
+    document.removeEventListener('wheel', handleWheel)
+  }
+}, [])
+```
+
+**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`.
+
+**Don't use passive when:** implementing custom swipe gestures, custom zoom controls, or any listener that needs `preventDefault()`.
+
+### 4.3 Use SWR for Automatic Deduplication
+
+**Impact: MEDIUM-HIGH (automatic deduplication)**
+
+SWR enables request deduplication, caching, and revalidation across component instances.
+
+**Incorrect: no deduplication, each instance fetches**
+
+```tsx
+function UserList() {
+  const [users, setUsers] = useState([])
+  useEffect(() => {
+    fetch('/api/users')
+      .then(r => r.json())
+      .then(setUsers)
+  }, [])
+}
+```
+
+**Correct: multiple instances share one request**
+
+```tsx
+import useSWR from 'swr'
+
+function UserList() {
+  const { data: users } = useSWR('/api/users', fetcher)
+}
+```
+
+**For immutable data:**
+
+```tsx
+import { useImmutableSWR } from '@/lib/swr'
+
+function StaticContent() {
+  const { data } = useImmutableSWR('/api/config', fetcher)
+}
+```
+
+**For mutations:**
+
+```tsx
+import { useSWRMutation } from 'swr/mutation'
+
+function UpdateButton() {
+  const { trigger } = useSWRMutation('/api/user', updateUser)
+  return <button onClick={() => trigger()}>Update</button>
+}
+```
+
+Reference: [https://swr.vercel.app](https://swr.vercel.app)
+
+### 4.4 Version and Minimize localStorage Data
+
+**Impact: MEDIUM (prevents schema conflicts, reduces storage size)**
+
+Add version prefix to keys and store only needed fields. Prevents schema conflicts and accidental storage of sensitive data.
+
+**Incorrect:**
+
+```typescript
+// No version, stores everything, no error handling
+localStorage.setItem('userConfig', JSON.stringify(fullUserObject))
+const data = localStorage.getItem('userConfig')
+```
+
+**Correct:**
+
+```typescript
+const VERSION = 'v2'
+
+function saveConfig(config: { theme: string; language: string }) {
+  try {
+    localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config))
+  } catch {
+    // Throws in incognito/private browsing, quota exceeded, or disabled
+  }
+}
+
+function loadConfig() {
+  try {
+    const data = localStorage.getItem(`userConfig:${VERSION}`)
+    return data ? JSON.parse(data) : null
+  } catch {
+    return null
+  }
+}
+
+// Migration from v1 to v2
+function migrate() {
+  try {
+    const v1 = localStorage.getItem('userConfig:v1')
+    if (v1) {
+      const old = JSON.parse(v1)
+      saveConfig({ theme: old.darkMode ? 'dark' : 'light', language: old.lang })
+      localStorage.removeItem('userConfig:v1')
+    }
+  } catch {}
+}
+```
+
+**Store minimal fields from server responses:**
+
+```typescript
+// User object has 20+ fields, only store what UI needs
+function cachePrefs(user: FullUser) {
+  try {
+    localStorage.setItem('prefs:v1', JSON.stringify({
+      theme: user.preferences.theme,
+      notifications: user.preferences.notifications
+    }))
+  } catch {}
+}
+```
+
+**Always wrap in try-catch:** `getItem()` and `setItem()` throw in incognito/private browsing (Safari, Firefox), when quota exceeded, or when disabled.
+
+**Benefits:** Schema evolution via versioning, reduced storage size, prevents storing tokens/PII/internal flags.
+
+---
+
+## 5. Re-render Optimization
+
+**Impact: MEDIUM**
+
+Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness.
+
+### 5.1 Calculate Derived State During Rendering
+
+**Impact: MEDIUM (avoids redundant renders and state drift)**
+
+If a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead.
+
+**Incorrect: redundant state and effect**
+
+```tsx
+function Form() {
+  const [firstName, setFirstName] = useState('First')
+  const [lastName, setLastName] = useState('Last')
+  const [fullName, setFullName] = useState('')
+
+  useEffect(() => {
+    setFullName(firstName + ' ' + lastName)
+  }, [firstName, lastName])
+
+  return <p>{fullName}</p>
+}
+```
+
+**Correct: derive during render**
+
+```tsx
+function Form() {
+  const [firstName, setFirstName] = useState('First')
+  const [lastName, setLastName] = useState('Last')
+  const fullName = firstName + ' ' + lastName
+
+  return <p>{fullName}</p>
+}
+```
+
+Reference: [https://react.dev/learn/you-might-not-need-an-effect](https://react.dev/learn/you-might-not-need-an-effect)
+
+### 5.2 Defer State Reads to Usage Point
+
+**Impact: MEDIUM (avoids unnecessary subscriptions)**
+
+Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.
+
+**Incorrect: subscribes to all searchParams changes**
+
+```tsx
+function ShareButton({ chatId }: { chatId: string }) {
+  const searchParams = useSearchParams()
+
+  const handleShare = () => {
+    const ref = searchParams.get('ref')
+    shareChat(chatId, { ref })
+  }
+
+  return <button onClick={handleShare}>Share</button>
+}
+```
+
+**Correct: reads on demand, no subscription**
+
+```tsx
+function ShareButton({ chatId }: { chatId: string }) {
+  const handleShare = () => {
+    const params = new URLSearchParams(window.location.search)
+    const ref = params.get('ref')
+    shareChat(chatId, { ref })
+  }
+
+  return <button onClick={handleShare}>Share</button>
+}
+```
+
+### 5.3 Do not wrap a simple expression with a primitive result type in useMemo
+
+**Impact: LOW-MEDIUM (wasted computation on every render)**
+
+When an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in `useMemo`.
+
+Calling `useMemo` and comparing hook dependencies may consume more resources than the expression itself.
+
+**Incorrect:**
+
+```tsx
+function Header({ user, notifications }: Props) {
+  const isLoading = useMemo(() => {
+    return user.isLoading || notifications.isLoading
+  }, [user.isLoading, notifications.isLoading])
+
+  if (isLoading) return <Skeleton />
+  // return some markup
+}
+```
+
+**Correct:**
+
+```tsx
+function Header({ user, notifications }: Props) {
+  const isLoading = user.isLoading || notifications.isLoading
+
+  if (isLoading) return <Skeleton />
+  // return some markup
+}
+```
+
+### 5.4 Extract Default Non-primitive Parameter Value from Memoized Component to Constant
+
+**Impact: MEDIUM (restores memoization by using a constant for default value)**
+
+When memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in `memo()`.
+
+To address this issue, extract the default value into a constant.
+
+**Incorrect: `onClick` has different values on every rerender**
+
+```tsx
+const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {
+  // ...
+})
+
+// Used without optional onClick
+<UserAvatar />
+```
+
+**Correct: stable default value**
+
+```tsx
+const NOOP = () => {};
+
+const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {
+  // ...
+})
+
+// Used without optional onClick
+<UserAvatar />
+```
+
+### 5.5 Extract to Memoized Components
+
+**Impact: MEDIUM (enables early returns)**
+
+Extract expensive work into memoized components to enable early returns before computation.
+
+**Incorrect: computes avatar even when loading**
+
+```tsx
+function Profile({ user, loading }: Props) {
+  const avatar = useMemo(() => {
+    const id = computeAvatarId(user)
+    return <Avatar id={id} />
+  }, [user])
+
+  if (loading) return <Skeleton />
+  return <div>{avatar}</div>
+}
+```
+
+**Correct: skips computation when loading**
+
+```tsx
+const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
+  const id = useMemo(() => computeAvatarId(user), [user])
+  return <Avatar id={id} />
+})
+
+function Profile({ user, loading }: Props) {
+  if (loading) return <Skeleton />
+  return (
+    <div>
+      <UserAvatar user={user} />
+    </div>
+  )
+}
+```
+
+**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, manual memoization with `memo()` and `useMemo()` is not necessary. The compiler automatically optimizes re-renders.
+
+### 5.6 Narrow Effect Dependencies
+
+**Impact: LOW (minimizes effect re-runs)**
+
+Specify primitive dependencies instead of objects to minimize effect re-runs.
+
+**Incorrect: re-runs on any user field change**
+
+```tsx
+useEffect(() => {
+  console.log(user.id)
+}, [user])
+```
+
+**Correct: re-runs only when id changes**
+
+```tsx
+useEffect(() => {
+  console.log(user.id)
+}, [user.id])
+```
+
+**For derived state, compute outside effect:**
+
+```tsx
+// Incorrect: runs on width=767, 766, 765...
+useEffect(() => {
+  if (width < 768) {
+    enableMobileMode()
+  }
+}, [width])
+
+// Correct: runs only on boolean transition
+const isMobile = width < 768
+useEffect(() => {
+  if (isMobile) {
+    enableMobileMode()
+  }
+}, [isMobile])
+```
+
+### 5.7 Put Interaction Logic in Event Handlers
+
+**Impact: MEDIUM (avoids effect re-runs and duplicate side effects)**
+
+If a side effect is triggered by a specific user action (submit, click, drag), run it in that event handler. Do not model the action as state + effect; it makes effects re-run on unrelated changes and can duplicate the action.
+
+**Incorrect: event modeled as state + effect**
+
+```tsx
+function Form() {
+  const [submitted, setSubmitted] = useState(false)
+  const theme = useContext(ThemeContext)
+
+  useEffect(() => {
+    if (submitted) {
+      post('/api/register')
+      showToast('Registered', theme)
+    }
+  }, [submitted, theme])
+
+  return <button onClick={() => setSubmitted(true)}>Submit</button>
+}
+```
+
+**Correct: do it in the handler**
+
+```tsx
+function Form() {
+  const theme = useContext(ThemeContext)
+
+  function handleSubmit() {
+    post('/api/register')
+    showToast('Registered', theme)
+  }
+
+  return <button onClick={handleSubmit}>Submit</button>
+}
+```
+
+Reference: [https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler)
+
+### 5.8 Subscribe to Derived State
+
+**Impact: MEDIUM (reduces re-render frequency)**
+
+Subscribe to derived boolean state instead of continuous values to reduce re-render frequency.
+
+**Incorrect: re-renders on every pixel change**
+
+```tsx
+function Sidebar() {
+  const width = useWindowWidth()  // updates continuously
+  const isMobile = width < 768
+  return <nav className={isMobile ? 'mobile' : 'desktop'} />
+}
+```
+
+**Correct: re-renders only when boolean changes**
+
+```tsx
+function Sidebar() {
+  const isMobile = useMediaQuery('(max-width: 767px)')
+  return <nav className={isMobile ? 'mobile' : 'desktop'} />
+}
+```
+
+### 5.9 Use Functional setState Updates
+
+**Impact: MEDIUM (prevents stale closures and unnecessary callback recreations)**
+
+When updating state based on the current state value, use the functional update form of setState instead of directly referencing the state variable. This prevents stale closures, eliminates unnecessary dependencies, and creates stable callback references.
+
+**Incorrect: requires state as dependency**
+
+```tsx
+function TodoList() {
+  const [items, setItems] = useState(initialItems)
+  
+  // Callback must depend on items, recreated on every items change
+  const addItems = useCallback((newItems: Item[]) => {
+    setItems([...items, ...newItems])
+  }, [items])  // ❌ items dependency causes recreations
+  
+  // Risk of stale closure if dependency is forgotten
+  const removeItem = useCallback((id: string) => {
+    setItems(items.filter(item => item.id !== id))
+  }, [])  // ❌ Missing items dependency - will use stale items!
+  
+  return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
+}
+```
+
+The first callback is recreated every time `items` changes, which can cause child components to re-render unnecessarily. The second callback has a stale closure bug—it will always reference the initial `items` value.
+
+**Correct: stable callbacks, no stale closures**
+
+```tsx
+function TodoList() {
+  const [items, setItems] = useState(initialItems)
+  
+  // Stable callback, never recreated
+  const addItems = useCallback((newItems: Item[]) => {
+    setItems(curr => [...curr, ...newItems])
+  }, [])  // ✅ No dependencies needed
+  
+  // Always uses latest state, no stale closure risk
+  const removeItem = useCallback((id: string) => {
+    setItems(curr => curr.filter(item => item.id !== id))
+  }, [])  // ✅ Safe and stable
+  
+  return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
+}
+```
+
+**Benefits:**
+
+1. **Stable callback references** - Callbacks don't need to be recreated when state changes
+
+2. **No stale closures** - Always operates on the latest state value
+
+3. **Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks
+
+4. **Prevents bugs** - Eliminates the most common source of React closure bugs
+
+**When to use functional updates:**
+
+- Any setState that depends on the current state value
+
+- Inside useCallback/useMemo when state is needed
+
+- Event handlers that reference state
+
+- Async operations that update state
+
+**When direct updates are fine:**
+
+- Setting state to a static value: `setCount(0)`
+
+- Setting state from props/arguments only: `setName(newName)`
+
+- State doesn't depend on previous value
+
+**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler can automatically optimize some cases, but functional updates are still recommended for correctness and to prevent stale closure bugs.
+
+### 5.10 Use Lazy State Initialization
+
+**Impact: MEDIUM (wasted computation on every render)**
+
+Pass a function to `useState` for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.
+
+**Incorrect: runs on every render**
+
+```tsx
+function FilteredList({ items }: { items: Item[] }) {
+  // buildSearchIndex() runs on EVERY render, even after initialization
+  const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
+  const [query, setQuery] = useState('')
+  
+  // When query changes, buildSearchIndex runs again unnecessarily
+  return <SearchResults index={searchIndex} query={query} />
+}
+
+function UserProfile() {
+  // JSON.parse runs on every render
+  const [settings, setSettings] = useState(
+    JSON.parse(localStorage.getItem('settings') || '{}')
+  )
+  
+  return <SettingsForm settings={settings} onChange={setSettings} />
+}
+```
+
+**Correct: runs only once**
+
+```tsx
+function FilteredList({ items }: { items: Item[] }) {
+  // buildSearchIndex() runs ONLY on initial render
+  const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
+  const [query, setQuery] = useState('')
+  
+  return <SearchResults index={searchIndex} query={query} />
+}
+
+function UserProfile() {
+  // JSON.parse runs only on initial render
+  const [settings, setSettings] = useState(() => {
+    const stored = localStorage.getItem('settings')
+    return stored ? JSON.parse(stored) : {}
+  })
+  
+  return <SettingsForm settings={settings} onChange={setSettings} />
+}
+```
+
+Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
+
+For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.
+
+### 5.11 Use Transitions for Non-Urgent Updates
+
+**Impact: MEDIUM (maintains UI responsiveness)**
+
+Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
+
+**Incorrect: blocks UI on every scroll**
+
+```tsx
+function ScrollTracker() {
+  const [scrollY, setScrollY] = useState(0)
+  useEffect(() => {
+    const handler = () => setScrollY(window.scrollY)
+    window.addEventListener('scroll', handler, { passive: true })
+    return () => window.removeEventListener('scroll', handler)
+  }, [])
+}
+```
+
+**Correct: non-blocking updates**
+
+```tsx
+import { startTransition } from 'react'
+
+function ScrollTracker() {
+  const [scrollY, setScrollY] = useState(0)
+  useEffect(() => {
+    const handler = () => {
+      startTransition(() => setScrollY(window.scrollY))
+    }
+    window.addEventListener('scroll', handler, { passive: true })
+    return () => window.removeEventListener('scroll', handler)
+  }, [])
+}
+```
+
+### 5.12 Use useRef for Transient Values
+
+**Impact: MEDIUM (avoids unnecessary re-renders on frequent updates)**
+
+When a value changes frequently and you don't want a re-render on every update (e.g., mouse trackers, intervals, transient flags), store it in `useRef` instead of `useState`. Keep component state for UI; use refs for temporary DOM-adjacent values. Updating a ref does not trigger a re-render.
+
+**Incorrect: renders every update**
+
+```tsx
+function Tracker() {
+  const [lastX, setLastX] = useState(0)
+
+  useEffect(() => {
+    const onMove = (e: MouseEvent) => setLastX(e.clientX)
+    window.addEventListener('mousemove', onMove)
+    return () => window.removeEventListener('mousemove', onMove)
+  }, [])
+
+  return (
+    <div
+      style={{
+        position: 'fixed',
+        top: 0,
+        left: lastX,
+        width: 8,
+        height: 8,
+        background: 'black',
+      }}
+    />
+  )
+}
+```
+
+**Correct: no re-render for tracking**
+
+```tsx
+function Tracker() {
+  const lastXRef = useRef(0)
+  const dotRef = useRef<HTMLDivElement>(null)
+
+  useEffect(() => {
+    const onMove = (e: MouseEvent) => {
+      lastXRef.current = e.clientX
+      const node = dotRef.current
+      if (node) {
+        node.style.transform = `translateX(${e.clientX}px)`
+      }
+    }
+    window.addEventListener('mousemove', onMove)
+    return () => window.removeEventListener('mousemove', onMove)
+  }, [])
+
+  return (
+    <div
+      ref={dotRef}
+      style={{
+        position: 'fixed',
+        top: 0,
+        left: 0,
+        width: 8,
+        height: 8,
+        background: 'black',
+        transform: 'translateX(0px)',
+      }}
+    />
+  )
+}
+```
+
+---
+
+## 6. Rendering Performance
+
+**Impact: MEDIUM**
+
+Optimizing the rendering process reduces the work the browser needs to do.
+
+### 6.1 Animate SVG Wrapper Instead of SVG Element
+
+**Impact: LOW (enables hardware acceleration)**
+
+Many browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a `<div>` and animate the wrapper instead.
+
+**Incorrect: animating SVG directly - no hardware acceleration**
+
+```tsx
+function LoadingSpinner() {
+  return (
+    <svg 
+      className="animate-spin"
+      width="24" 
+      height="24" 
+      viewBox="0 0 24 24"
+    >
+      <circle cx="12" cy="12" r="10" stroke="currentColor" />
+    </svg>
+  )
+}
+```
+
+**Correct: animating wrapper div - hardware accelerated**
+
+```tsx
+function LoadingSpinner() {
+  return (
+    <div className="animate-spin">
+      <svg 
+        width="24" 
+        height="24" 
+        viewBox="0 0 24 24"
+      >
+        <circle cx="12" cy="12" r="10" stroke="currentColor" />
+      </svg>
+    </div>
+  )
+}
+```
+
+This applies to all CSS transforms and transitions (`transform`, `opacity`, `translate`, `scale`, `rotate`). The wrapper div allows browsers to use GPU acceleration for smoother animations.
+
+### 6.2 CSS content-visibility for Long Lists
+
+**Impact: HIGH (faster initial render)**
+
+Apply `content-visibility: auto` to defer off-screen rendering.
+
+**CSS:**
+
+```css
+.message-item {
+  content-visibility: auto;
+  contain-intrinsic-size: 0 80px;
+}
+```
+
+**Example:**
+
+```tsx
+function MessageList({ messages }: { messages: Message[] }) {
+  return (
+    <div className="overflow-y-auto h-screen">
+      {messages.map(msg => (
+        <div key={msg.id} className="message-item">
+          <Avatar user={msg.author} />
+          <div>{msg.content}</div>
+        </div>
+      ))}
+    </div>
+  )
+}
+```
+
+For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render).
+
+### 6.3 Hoist Static JSX Elements
+
+**Impact: LOW (avoids re-creation)**
+
+Extract static JSX outside components to avoid re-creation.
+
+**Incorrect: recreates element every render**
+
+```tsx
+function LoadingSkeleton() {
+  return <div className="animate-pulse h-20 bg-gray-200" />
+}
+
+function Container() {
+  return (
+    <div>
+      {loading && <LoadingSkeleton />}
+    </div>
+  )
+}
+```
+
+**Correct: reuses same element**
+
+```tsx
+const loadingSkeleton = (
+  <div className="animate-pulse h-20 bg-gray-200" />
+)
+
+function Container() {
+  return (
+    <div>
+      {loading && loadingSkeleton}
+    </div>
+  )
+}
+```
+
+This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.
+
+**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler automatically hoists static JSX elements and optimizes component re-renders, making manual hoisting unnecessary.
+
+### 6.4 Optimize SVG Precision
+
+**Impact: LOW (reduces file size)**
+
+Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.
+
+**Incorrect: excessive precision**
+
+```svg
+<path d="M 10.293847 20.847362 L 30.938472 40.192837" />
+```
+
+**Correct: 1 decimal place**
+
+```svg
+<path d="M 10.3 20.8 L 30.9 40.2" />
+```
+
+**Automate with SVGO:**
+
+```bash
+npx svgo --precision=1 --multipass icon.svg
+```
+
+### 6.5 Prevent Hydration Mismatch Without Flickering
+
+**Impact: MEDIUM (avoids visual flicker and hydration errors)**
+
+When rendering content that depends on client-side storage (localStorage, cookies), avoid both SSR breakage and post-hydration flickering by injecting a synchronous script that updates the DOM before React hydrates.
+
+**Incorrect: breaks SSR**
+
+```tsx
+function ThemeWrapper({ children }: { children: ReactNode }) {
+  // localStorage is not available on server - throws error
+  const theme = localStorage.getItem('theme') || 'light'
+  
+  return (
+    <div className={theme}>
+      {children}
+    </div>
+  )
+}
+```
+
+Server-side rendering will fail because `localStorage` is undefined.
+
+**Incorrect: visual flickering**
+
+```tsx
+function ThemeWrapper({ children }: { children: ReactNode }) {
+  const [theme, setTheme] = useState('light')
+  
+  useEffect(() => {
+    // Runs after hydration - causes visible flash
+    const stored = localStorage.getItem('theme')
+    if (stored) {
+      setTheme(stored)
+    }
+  }, [])
+  
+  return (
+    <div className={theme}>
+      {children}
+    </div>
+  )
+}
+```
+
+Component first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content.
+
+**Correct: no flicker, no hydration mismatch**
+
+```tsx
+function ThemeWrapper({ children }: { children: ReactNode }) {
+  return (
+    <>
+      <div id="theme-wrapper">
+        {children}
+      </div>
+      <script
+        dangerouslySetInnerHTML={{
+          __html: `
+            (function() {
+              try {
+                var theme = localStorage.getItem('theme') || 'light';
+                var el = document.getElementById('theme-wrapper');
+                if (el) el.className = theme;
+              } catch (e) {}
+            })();
+          `,
+        }}
+      />
+    </>
+  )
+}
+```
+
+The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.
+
+This pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values.
+
+### 6.6 Suppress Expected Hydration Mismatches
+
+**Impact: LOW-MEDIUM (avoids noisy hydration warnings for known differences)**
+
+In SSR frameworks (e.g., Next.js), some values are intentionally different on server vs client (random IDs, dates, locale/timezone formatting). For these *expected* mismatches, wrap the dynamic text in an element with `suppressHydrationWarning` to prevent noisy warnings. Do not use this to hide real bugs. Don’t overuse it.
+
+**Incorrect: known mismatch warnings**
+
+```tsx
+function Timestamp() {
+  return <span>{new Date().toLocaleString()}</span>
+}
+```
+
+**Correct: suppress expected mismatch only**
+
+```tsx
+function Timestamp() {
+  return (
+    <span suppressHydrationWarning>
+      {new Date().toLocaleString()}
+    </span>
+  )
+}
+```
+
+### 6.7 Use Activity Component for Show/Hide
+
+**Impact: MEDIUM (preserves state/DOM)**
+
+Use React's `<Activity>` to preserve state/DOM for expensive components that frequently toggle visibility.
+
+**Usage:**
+
+```tsx
+import { Activity } from 'react'
+
+function Dropdown({ isOpen }: Props) {
+  return (
+    <Activity mode={isOpen ? 'visible' : 'hidden'}>
+      <ExpensiveMenu />
+    </Activity>
+  )
+}
+```
+
+Avoids expensive re-renders and state loss.
+
+### 6.8 Use Explicit Conditional Rendering
+
+**Impact: LOW (prevents rendering 0 or NaN)**
+
+Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.
+
+**Incorrect: renders "0" when count is 0**
+
+```tsx
+function Badge({ count }: { count: number }) {
+  return (
+    <div>
+      {count && <span className="badge">{count}</span>}
+    </div>
+  )
+}
+
+// When count = 0, renders: <div>0</div>
+// When count = 5, renders: <div><span class="badge">5</span></div>
+```
+
+**Correct: renders nothing when count is 0**
+
+```tsx
+function Badge({ count }: { count: number }) {
+  return (
+    <div>
+      {count > 0 ? <span className="badge">{count}</span> : null}
+    </div>
+  )
+}
+
+// When count = 0, renders: <div></div>
+// When count = 5, renders: <div><span class="badge">5</span></div>
+```
+
+### 6.9 Use useTransition Over Manual Loading States
+
+**Impact: LOW (reduces re-renders and improves code clarity)**
+
+Use `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions.
+
+**Incorrect: manual loading state**
+
+```tsx
+function SearchResults() {
+  const [query, setQuery] = useState('')
+  const [results, setResults] = useState([])
+  const [isLoading, setIsLoading] = useState(false)
+
+  const handleSearch = async (value: string) => {
+    setIsLoading(true)
+    setQuery(value)
+    const data = await fetchResults(value)
+    setResults(data)
+    setIsLoading(false)
+  }
+
+  return (
+    <>
+      <input onChange={(e) => handleSearch(e.target.value)} />
+      {isLoading && <Spinner />}
+      <ResultsList results={results} />
+    </>
+  )
+}
+```
+
+**Correct: useTransition with built-in pending state**
+
+```tsx
+import { useTransition, useState } from 'react'
+
+function SearchResults() {
+  const [query, setQuery] = useState('')
+  const [results, setResults] = useState([])
+  const [isPending, startTransition] = useTransition()
+
+  const handleSearch = (value: string) => {
+    setQuery(value) // Update input immediately
+    
+    startTransition(async () => {
+      // Fetch and update results
+      const data = await fetchResults(value)
+      setResults(data)
+    })
+  }
+
+  return (
+    <>
+      <input onChange={(e) => handleSearch(e.target.value)} />
+      {isPending && <Spinner />}
+      <ResultsList results={results} />
+    </>
+  )
+}
+```
+
+**Benefits:**
+
+- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)`
+
+- **Error resilience**: Pending state correctly resets even if the transition throws
+
+- **Better responsiveness**: Keeps the UI responsive during updates
+
+- **Interrupt handling**: New transitions automatically cancel pending ones
+
+Reference: [https://react.dev/reference/react/useTransition](https://react.dev/reference/react/useTransition)
+
+---
+
+## 7. JavaScript Performance
+
+**Impact: LOW-MEDIUM**
+
+Micro-optimizations for hot paths can add up to meaningful improvements.
+
+### 7.1 Avoid Layout Thrashing
+
+**Impact: MEDIUM (prevents forced synchronous layouts and reduces performance bottlenecks)**
+
+Avoid interleaving style writes with layout reads. When you read a layout property (like `offsetWidth`, `getBoundingClientRect()`, or `getComputedStyle()`) between style changes, the browser is forced to trigger a synchronous reflow.
+
+**This is OK: browser batches style changes**
+
+```typescript
+function updateElementStyles(element: HTMLElement) {
+  // Each line invalidates style, but browser batches the recalculation
+  element.style.width = '100px'
+  element.style.height = '200px'
+  element.style.backgroundColor = 'blue'
+  element.style.border = '1px solid black'
+}
+```
+
+**Incorrect: interleaved reads and writes force reflows**
+
+```typescript
+function layoutThrashing(element: HTMLElement) {
+  element.style.width = '100px'
+  const width = element.offsetWidth  // Forces reflow
+  element.style.height = '200px'
+  const height = element.offsetHeight  // Forces another reflow
+}
+```
+
+**Correct: batch writes, then read once**
+
+```typescript
+function updateElementStyles(element: HTMLElement) {
+  // Batch all writes together
+  element.style.width = '100px'
+  element.style.height = '200px'
+  element.style.backgroundColor = 'blue'
+  element.style.border = '1px solid black'
+  
+  // Read after all writes are done (single reflow)
+  const { width, height } = element.getBoundingClientRect()
+}
+```
+
+**Correct: batch reads, then writes**
+
+```typescript
+function updateElementStyles(element: HTMLElement) {
+  element.classList.add('highlighted-box')
+  
+  const { width, height } = element.getBoundingClientRect()
+}
+```
+
+**Better: use CSS classes**
+
+**React example:**
+
+```tsx
+// Incorrect: interleaving style changes with layout queries
+function Box({ isHighlighted }: { isHighlighted: boolean }) {
+  const ref = useRef<HTMLDivElement>(null)
+  
+  useEffect(() => {
+    if (ref.current && isHighlighted) {
+      ref.current.style.width = '100px'
+      const width = ref.current.offsetWidth // Forces layout
+      ref.current.style.height = '200px'
+    }
+  }, [isHighlighted])
+  
+  return <div ref={ref}>Content</div>
+}
+
+// Correct: toggle class
+function Box({ isHighlighted }: { isHighlighted: boolean }) {
+  return (
+    <div className={isHighlighted ? 'highlighted-box' : ''}>
+      Content
+    </div>
+  )
+}
+```
+
+Prefer CSS classes over inline styles when possible. CSS files are cached by the browser, and classes provide better separation of concerns and are easier to maintain.
+
+See [this gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) and [CSS Triggers](https://csstriggers.com/) for more information on layout-forcing operations.
+
+### 7.2 Build Index Maps for Repeated Lookups
+
+**Impact: LOW-MEDIUM (1M ops to 2K ops)**
+
+Multiple `.find()` calls by the same key should use a Map.
+
+**Incorrect (O(n) per lookup):**
+
+```typescript
+function processOrders(orders: Order[], users: User[]) {
+  return orders.map(order => ({
+    ...order,
+    user: users.find(u => u.id === order.userId)
+  }))
+}
+```
+
+**Correct (O(1) per lookup):**
+
+```typescript
+function processOrders(orders: Order[], users: User[]) {
+  const userById = new Map(users.map(u => [u.id, u]))
+
+  return orders.map(order => ({
+    ...order,
+    user: userById.get(order.userId)
+  }))
+}
+```
+
+Build map once (O(n)), then all lookups are O(1).
+
+For 1000 orders × 1000 users: 1M ops → 2K ops.
+
+### 7.3 Cache Property Access in Loops
+
+**Impact: LOW-MEDIUM (reduces lookups)**
+
+Cache object property lookups in hot paths.
+
+**Incorrect: 3 lookups × N iterations**
+
+```typescript
+for (let i = 0; i < arr.length; i++) {
+  process(obj.config.settings.value)
+}
+```
+
+**Correct: 1 lookup total**
+
+```typescript
+const value = obj.config.settings.value
+const len = arr.length
+for (let i = 0; i < len; i++) {
+  process(value)
+}
+```
+
+### 7.4 Cache Repeated Function Calls
+
+**Impact: MEDIUM (avoid redundant computation)**
+
+Use a module-level Map to cache function results when the same function is called repeatedly with the same inputs during render.
+
+**Incorrect: redundant computation**
+
+```typescript
+function ProjectList({ projects }: { projects: Project[] }) {
+  return (
+    <div>
+      {projects.map(project => {
+        // slugify() called 100+ times for same project names
+        const slug = slugify(project.name)
+        
+        return <ProjectCard key={project.id} slug={slug} />
+      })}
+    </div>
+  )
+}
+```
+
+**Correct: cached results**
+
+```typescript
+// Module-level cache
+const slugifyCache = new Map<string, string>()
+
+function cachedSlugify(text: string): string {
+  if (slugifyCache.has(text)) {
+    return slugifyCache.get(text)!
+  }
+  const result = slugify(text)
+  slugifyCache.set(text, result)
+  return result
+}
+
+function ProjectList({ projects }: { projects: Project[] }) {
+  return (
+    <div>
+      {projects.map(project => {
+        // Computed only once per unique project name
+        const slug = cachedSlugify(project.name)
+        
+        return <ProjectCard key={project.id} slug={slug} />
+      })}
+    </div>
+  )
+}
+```
+
+**Simpler pattern for single-value functions:**
+
+```typescript
+let isLoggedInCache: boolean | null = null
+
+function isLoggedIn(): boolean {
+  if (isLoggedInCache !== null) {
+    return isLoggedInCache
+  }
+  
+  isLoggedInCache = document.cookie.includes('auth=')
+  return isLoggedInCache
+}
+
+// Clear cache when auth changes
+function onAuthChange() {
+  isLoggedInCache = null
+}
+```
+
+Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
+
+Reference: [https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)
+
+### 7.5 Cache Storage API Calls
+
+**Impact: LOW-MEDIUM (reduces expensive I/O)**
+
+`localStorage`, `sessionStorage`, and `document.cookie` are synchronous and expensive. Cache reads in memory.
+
+**Incorrect: reads storage on every call**
+
+```typescript
+function getTheme() {
+  return localStorage.getItem('theme') ?? 'light'
+}
+// Called 10 times = 10 storage reads
+```
+
+**Correct: Map cache**
+
+```typescript
+const storageCache = new Map<string, string | null>()
+
+function getLocalStorage(key: string) {
+  if (!storageCache.has(key)) {
+    storageCache.set(key, localStorage.getItem(key))
+  }
+  return storageCache.get(key)
+}
+
+function setLocalStorage(key: string, value: string) {
+  localStorage.setItem(key, value)
+  storageCache.set(key, value)  // keep cache in sync
+}
+```
+
+Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
+
+**Cookie caching:**
+
+```typescript
+let cookieCache: Record<string, string> | null = null
+
+function getCookie(name: string) {
+  if (!cookieCache) {
+    cookieCache = Object.fromEntries(
+      document.cookie.split('; ').map(c => c.split('='))
+    )
+  }
+  return cookieCache[name]
+}
+```
+
+**Important: invalidate on external changes**
+
+```typescript
+window.addEventListener('storage', (e) => {
+  if (e.key) storageCache.delete(e.key)
+})
+
+document.addEventListener('visibilitychange', () => {
+  if (document.visibilityState === 'visible') {
+    storageCache.clear()
+  }
+})
+```
+
+If storage can change externally (another tab, server-set cookies), invalidate cache:
+
+### 7.6 Combine Multiple Array Iterations
+
+**Impact: LOW-MEDIUM (reduces iterations)**
+
+Multiple `.filter()` or `.map()` calls iterate the array multiple times. Combine into one loop.
+
+**Incorrect: 3 iterations**
+
+```typescript
+const admins = users.filter(u => u.isAdmin)
+const testers = users.filter(u => u.isTester)
+const inactive = users.filter(u => !u.isActive)
+```
+
+**Correct: 1 iteration**
+
+```typescript
+const admins: User[] = []
+const testers: User[] = []
+const inactive: User[] = []
+
+for (const user of users) {
+  if (user.isAdmin) admins.push(user)
+  if (user.isTester) testers.push(user)
+  if (!user.isActive) inactive.push(user)
+}
+```
+
+### 7.7 Early Length Check for Array Comparisons
+
+**Impact: MEDIUM-HIGH (avoids expensive operations when lengths differ)**
+
+When comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal.
+
+In real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops).
+
+**Incorrect: always runs expensive comparison**
+
+```typescript
+function hasChanges(current: string[], original: string[]) {
+  // Always sorts and joins, even when lengths differ
+  return current.sort().join() !== original.sort().join()
+}
+```
+
+Two O(n log n) sorts run even when `current.length` is 5 and `original.length` is 100. There is also overhead of joining the arrays and comparing the strings.
+
+**Correct (O(1) length check first):**
+
+```typescript
+function hasChanges(current: string[], original: string[]) {
+  // Early return if lengths differ
+  if (current.length !== original.length) {
+    return true
+  }
+  // Only sort when lengths match
+  const currentSorted = current.toSorted()
+  const originalSorted = original.toSorted()
+  for (let i = 0; i < currentSorted.length; i++) {
+    if (currentSorted[i] !== originalSorted[i]) {
+      return true
+    }
+  }
+  return false
+}
+```
+
+This new approach is more efficient because:
+
+- It avoids the overhead of sorting and joining the arrays when lengths differ
+
+- It avoids consuming memory for the joined strings (especially important for large arrays)
+
+- It avoids mutating the original arrays
+
+- It returns early when a difference is found
+
+### 7.8 Early Return from Functions
+
+**Impact: LOW-MEDIUM (avoids unnecessary computation)**
+
+Return early when result is determined to skip unnecessary processing.
+
+**Incorrect: processes all items even after finding answer**
+
+```typescript
+function validateUsers(users: User[]) {
+  let hasError = false
+  let errorMessage = ''
+  
+  for (const user of users) {
+    if (!user.email) {
+      hasError = true
+      errorMessage = 'Email required'
+    }
+    if (!user.name) {
+      hasError = true
+      errorMessage = 'Name required'
+    }
+    // Continues checking all users even after error found
+  }
+  
+  return hasError ? { valid: false, error: errorMessage } : { valid: true }
+}
+```
+
+**Correct: returns immediately on first error**
+
+```typescript
+function validateUsers(users: User[]) {
+  for (const user of users) {
+    if (!user.email) {
+      return { valid: false, error: 'Email required' }
+    }
+    if (!user.name) {
+      return { valid: false, error: 'Name required' }
+    }
+  }
+
+  return { valid: true }
+}
+```
+
+### 7.9 Hoist RegExp Creation
+
+**Impact: LOW-MEDIUM (avoids recreation)**
+
+Don't create RegExp inside render. Hoist to module scope or memoize with `useMemo()`.
+
+**Incorrect: new RegExp every render**
+
+```tsx
+function Highlighter({ text, query }: Props) {
+  const regex = new RegExp(`(${query})`, 'gi')
+  const parts = text.split(regex)
+  return <>{parts.map((part, i) => ...)}</>
+}
+```
+
+**Correct: memoize or hoist**
+
+```tsx
+const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+
+function Highlighter({ text, query }: Props) {
+  const regex = useMemo(
+    () => new RegExp(`(${escapeRegex(query)})`, 'gi'),
+    [query]
+  )
+  const parts = text.split(regex)
+  return <>{parts.map((part, i) => ...)}</>
+}
+```
+
+**Warning: global regex has mutable state**
+
+```typescript
+const regex = /foo/g
+regex.test('foo')  // true, lastIndex = 3
+regex.test('foo')  // false, lastIndex = 0
+```
+
+Global regex (`/g`) has mutable `lastIndex` state:
+
+### 7.10 Use Loop for Min/Max Instead of Sort
+
+**Impact: LOW (O(n) instead of O(n log n))**
+
+Finding the smallest or largest element only requires a single pass through the array. Sorting is wasteful and slower.
+
+**Incorrect (O(n log n) - sort to find latest):**
+
+```typescript
+interface Project {
+  id: string
+  name: string
+  updatedAt: number
+}
+
+function getLatestProject(projects: Project[]) {
+  const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
+  return sorted[0]
+}
+```
+
+Sorts the entire array just to find the maximum value.
+
+**Incorrect (O(n log n) - sort for oldest and newest):**
+
+```typescript
+function getOldestAndNewest(projects: Project[]) {
+  const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
+  return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
+}
+```
+
+Still sorts unnecessarily when only min/max are needed.
+
+**Correct (O(n) - single loop):**
+
+```typescript
+function getLatestProject(projects: Project[]) {
+  if (projects.length === 0) return null
+  
+  let latest = projects[0]
+  
+  for (let i = 1; i < projects.length; i++) {
+    if (projects[i].updatedAt > latest.updatedAt) {
+      latest = projects[i]
+    }
+  }
+  
+  return latest
+}
+
+function getOldestAndNewest(projects: Project[]) {
+  if (projects.length === 0) return { oldest: null, newest: null }
+  
+  let oldest = projects[0]
+  let newest = projects[0]
+  
+  for (let i = 1; i < projects.length; i++) {
+    if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
+    if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]
+  }
+  
+  return { oldest, newest }
+}
+```
+
+Single pass through the array, no copying, no sorting.
+
+**Alternative: Math.min/Math.max for small arrays**
+
+```typescript
+const numbers = [5, 2, 8, 1, 9]
+const min = Math.min(...numbers)
+const max = Math.max(...numbers)
+```
+
+This works for small arrays, but can be slower or just throw an error for very large arrays due to spread operator limitations. Maximal array length is approximately 124000 in Chrome 143 and 638000 in Safari 18; exact numbers may vary - see [the fiddle](https://jsfiddle.net/qw1jabsx/4/). Use the loop approach for reliability.
+
+### 7.11 Use Set/Map for O(1) Lookups
+
+**Impact: LOW-MEDIUM (O(n) to O(1))**
+
+Convert arrays to Set/Map for repeated membership checks.
+
+**Incorrect (O(n) per check):**
+
+```typescript
+const allowedIds = ['a', 'b', 'c', ...]
+items.filter(item => allowedIds.includes(item.id))
+```
+
+**Correct (O(1) per check):**
+
+```typescript
+const allowedIds = new Set(['a', 'b', 'c', ...])
+items.filter(item => allowedIds.has(item.id))
+```
+
+### 7.12 Use toSorted() Instead of sort() for Immutability
+
+**Impact: MEDIUM-HIGH (prevents mutation bugs in React state)**
+
+`.sort()` mutates the array in place, which can cause bugs with React state and props. Use `.toSorted()` to create a new sorted array without mutation.
+
+**Incorrect: mutates original array**
+
+```typescript
+function UserList({ users }: { users: User[] }) {
+  // Mutates the users prop array!
+  const sorted = useMemo(
+    () => users.sort((a, b) => a.name.localeCompare(b.name)),
+    [users]
+  )
+  return <div>{sorted.map(renderUser)}</div>
+}
+```
+
+**Correct: creates new array**
+
+```typescript
+function UserList({ users }: { users: User[] }) {
+  // Creates new sorted array, original unchanged
+  const sorted = useMemo(
+    () => users.toSorted((a, b) => a.name.localeCompare(b.name)),
+    [users]
+  )
+  return <div>{sorted.map(renderUser)}</div>
+}
+```
+
+**Why this matters in React:**
+
+1. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only
+
+2. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior
+
+**Browser support: fallback for older browsers**
+
+```typescript
+// Fallback for older browsers
+const sorted = [...items].sort((a, b) => a.value - b.value)
+```
+
+`.toSorted()` is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator:
+
+**Other immutable array methods:**
+
+- `.toSorted()` - immutable sort
+
+- `.toReversed()` - immutable reverse
+
+- `.toSpliced()` - immutable splice
+
+- `.with()` - immutable element replacement
+
+---
+
+## 8. Advanced Patterns
+
+**Impact: LOW**
+
+Advanced patterns for specific cases that require careful implementation.
+
+### 8.1 Initialize App Once, Not Per Mount
+
+**Impact: LOW-MEDIUM (avoids duplicate init in development)**
+
+Do not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead.
+
+**Incorrect: runs twice in dev, re-runs on remount**
+
+```tsx
+function Comp() {
+  useEffect(() => {
+    loadFromStorage()
+    checkAuthToken()
+  }, [])
+
+  // ...
+}
+```
+
+**Correct: once per app load**
+
+```tsx
+let didInit = false
+
+function Comp() {
+  useEffect(() => {
+    if (didInit) return
+    didInit = true
+    loadFromStorage()
+    checkAuthToken()
+  }, [])
+
+  // ...
+}
+```
+
+Reference: [https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application)
+
+### 8.2 Store Event Handlers in Refs
+
+**Impact: LOW (stable subscriptions)**
+
+Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.
+
+**Incorrect: re-subscribes on every render**
+
+```tsx
+function useWindowEvent(event: string, handler: (e) => void) {
+  useEffect(() => {
+    window.addEventListener(event, handler)
+    return () => window.removeEventListener(event, handler)
+  }, [event, handler])
+}
+```
+
+**Correct: stable subscription**
+
+```tsx
+import { useEffectEvent } from 'react'
+
+function useWindowEvent(event: string, handler: (e) => void) {
+  const onEvent = useEffectEvent(handler)
+
+  useEffect(() => {
+    window.addEventListener(event, onEvent)
+    return () => window.removeEventListener(event, onEvent)
+  }, [event])
+}
+```
+
+**Alternative: use `useEffectEvent` if you're on latest React:**
+
+`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.
+
+### 8.3 useEffectEvent for Stable Callback Refs
+
+**Impact: LOW (prevents effect re-runs)**
+
+Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.
+
+**Incorrect: effect re-runs on every callback change**
+
+```tsx
+function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
+  const [query, setQuery] = useState('')
+
+  useEffect(() => {
+    const timeout = setTimeout(() => onSearch(query), 300)
+    return () => clearTimeout(timeout)
+  }, [query, onSearch])
+}
+```
+
+**Correct: using React's useEffectEvent**
+
+```tsx
+import { useEffectEvent } from 'react';
+
+function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
+  const [query, setQuery] = useState('')
+  const onSearchEvent = useEffectEvent(onSearch)
+
+  useEffect(() => {
+    const timeout = setTimeout(() => onSearchEvent(query), 300)
+    return () => clearTimeout(timeout)
+  }, [query])
+}
+```
+
+---
+
+## References
+
+1. [https://react.dev](https://react.dev)
+2. [https://nextjs.org](https://nextjs.org)
+3. [https://swr.vercel.app](https://swr.vercel.app)
+4. [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
+5. [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
+6. [https://vercel.com/blog/how-we-optimized-package-imports-in-next-js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
+7. [https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)

+ 2 - 0
.env.example

@@ -19,6 +19,8 @@
 # HOSTNAME=your-hostname
 
 # 数据库相关配置
+# 启用错误日志记录
+# ERROR_LOG_ENABLED=true
 # 数据库连接字符串
 # SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true
 # 日志数据库连接字符串

+ 14 - 6
.github/ISSUE_TEMPLATE/bug_report.md

@@ -7,14 +7,23 @@ assignees: ''
 
 ---
 
-**例行检查**
+## 提交前必读(请勿删除本节)
+
+- 文档:https://docs.newapi.ai/
+- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
+- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
+
+**您当前的 newapi 版本**
+
+请填写,例如:`v1.0.0`
+
+**提交确认**
 
 [//]: # (方框内删除已有的空格,填 x 号)
 + [ ] 我已确认目前没有类似 issue
-+ [ ] 我已确认我已升级到最新版本
-+ [ ] 我已完整查看过项目 README,尤其是常见问题部分
-+ [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈 
-+ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭**
++ [ ] 我已完整查看过文档 https://docs.newapi.ai/ 和项目 README,尤其是常见问题部分
++ [ ] 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
++ [ ] 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
 
 **问题描述**
 
@@ -23,4 +32,3 @@ assignees: ''
 **预期结果**
 
 **相关截图**
-如果没有的话,请删除此节。

+ 15 - 7
.github/ISSUE_TEMPLATE/bug_report_en.md

@@ -7,14 +7,23 @@ assignees: ''
 
 ---
 
-**Routine Checks**
+## Read This First (Do Not Remove This Section)
+
+- Docs: https://docs.newapi.ai/
+- Usage questions first: https://deepwiki.com/QuantumNous/new-api
+- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block.
+
+**Your current newapi version**
+
+Please fill this in, for example: `v1.0.0`
+
+**Submission Checks**
 
 [//]: # (Remove the space in the box and fill with an x)
-+ [ ] I have confirmed there are no similar issues currently
-+ [ ] I have confirmed I have upgraded to the latest version
-+ [ ] I have thoroughly read the project README, especially the FAQ section
-+ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback 
-+ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly**
++ [ ] I have confirmed there are no similar issues
++ [ ] I have thoroughly read the docs at https://docs.newapi.ai/ and the project README, especially the FAQ section
++ [ ] I have not removed any guidance or section headings from this template and will complete it as requested
++ [ ] I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly
 
 **Issue Description**
 
@@ -23,4 +32,3 @@ assignees: ''
 **Expected Result**
 
 **Related Screenshots**
-If none, please delete this section.

+ 6 - 3
.github/ISSUE_TEMPLATE/config.yml

@@ -1,5 +1,8 @@
 blank_issues_enabled: false
 contact_links:
-  - name: 项目群聊
-    url: https://private-user-images.githubusercontent.com/61247483/283011625-de536a8a-0161-47a7-a0a2-66ef6de81266.jpeg?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTEiLCJleHAiOjE3MDIyMjQzOTAsIm5iZiI6MTcwMjIyNDA5MCwicGF0aCI6Ii82MTI0NzQ4My8yODMwMTE2MjUtZGU1MzZhOGEtMDE2MS00N2E3LWEwYTItNjZlZjZkZTgxMjY2LmpwZWc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBSVdOSllBWDRDU1ZFSDUzQSUyRjIwMjMxMjEwJTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDIzMTIxMFQxNjAxMzBaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT02MGIxYmM3ZDQyYzBkOTA2ZTYyYmVmMzQ1NjY4NjM1YjY0NTUzNTM5NjE1NDZkYTIzODdhYTk4ZjZjODJmYzY2JlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCZhY3Rvcl9pZD0wJmtleV9pZD0wJnJlcG9faWQ9MCJ9.TJ8CTfOSwR0-CHS1KLfomqgL0e4YH1luy8lSLrkv5Zg
-    about: QQ 群:629454374
+  - name: 使用文档 / Documentation
+    url: https://docs.newapi.ai/
+    about: 提交 issue 前请先查阅文档,确认现有说明无法解决你的问题。
+  - name: 使用问题 / Usage Questions
+    url: https://deepwiki.com/QuantumNous/new-api
+    about: 使用、配置、接入等问题请优先在 DeepWiki 查询或提问。

+ 14 - 5
.github/ISSUE_TEMPLATE/feature_request.md

@@ -7,14 +7,23 @@ assignees: ''
 
 ---
 
-**例行检查**
+## 提交前必读(请勿删除本节)
+
+- 文档:https://docs.newapi.ai/
+- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
+- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
+
+**您当前的 newapi 版本**
+
+请填写,例如:`v1.0.0`
+
+**提交确认**
 
 [//]: # (方框内删除已有的空格,填 x 号)
 + [ ] 我已确认目前没有类似 issue
-+ [ ] 我已确认我已升级到最新版本
-+ [ ] 我已完整查看过项目 README,已确定现有版本无法满足需求
-+ [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈
-+ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭**
++ [ ] 我已完整查看过文档 https://docs.newapi.ai/ 和项目 README,已确定现有版本无法满足需求
++ [ ] 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
++ [ ] 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
 
 **功能描述**
 

+ 15 - 7
.github/ISSUE_TEMPLATE/feature_request_en.md

@@ -7,16 +7,24 @@ assignees: ''
 
 ---
 
-**Routine Checks**
+## Read This First (Do Not Remove This Section)
+
+- Docs: https://docs.newapi.ai/
+- Usage questions first: https://deepwiki.com/QuantumNous/new-api
+- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block.
+
+**Your current newapi version**
+
+Please fill this in, for example: `v1.0.0`
+
+**Submission Checks**
 
 [//]: # (Remove the space in the box and fill with an x)
-+ [ ] I have confirmed there are no similar issues currently
-+ [ ] I have confirmed I have upgraded to the latest version
-+ [ ] I have thoroughly read the project README and confirmed the current version cannot meet my needs
-+ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback
-+ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly**
++ [ ] I have confirmed there are no similar issues
++ [ ] I have thoroughly read the docs at https://docs.newapi.ai/ and the project README, and confirmed the current version cannot meet my needs
++ [ ] I have not removed any guidance or section headings from this template and will complete it as requested
++ [ ] I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly
 
 **Feature Description**
 
 **Use Case**
-

+ 28 - 0
.github/PULL_REQUEST_TEMPLATE.md

@@ -0,0 +1,28 @@
+# ⚠️ 提交说明 / PR Notice
+> [!IMPORTANT]
+>
+> - 请提供**人工撰写**的简洁摘要,避免直接粘贴未经整理的 AI 输出。
+
+## 📝 变更描述 / Description
+(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容)
+
+## 🚀 变更类型 / Type of change
+- [ ] 🐛 Bug 修复 (Bug fix) - *请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug*
+- [ ] ✨ 新功能 (New feature) - *重大特性建议先通过 Issue 沟通*
+- [ ] ⚡ 性能优化 / 重构 (Refactor)
+- [ ] 📝 文档更新 (Documentation)
+
+## 🔗 关联任务 / Related Issue
+- Closes # (如有)
+
+## ✅ 提交前检查项 / Checklist
+- [ ] **人工确认:** 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
+- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。
+- [ ] **Bug fix 说明:** 若此 PR 标记为 `Bug fix`,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
+- [ ] **变更理解:** 我已理解这些更改的工作原理及可能影响。
+- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
+- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
+- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
+
+## 📸 运行证明 / Proof of Work
+(请在此粘贴截图、关键日志或测试报告,以证明变更生效)

+ 12 - 2
.github/SECURITY.md

@@ -1,14 +1,24 @@
 # Security Policy
 
+> [!IMPORTANT]
+> **Bulk Reporting Policy:** If you need to submit multiple vulnerability reports in bulk, **you must contact us first** ([support@quantumnous.com](mailto:support@quantumnous.com)) to coordinate the submission process. Uncoordinated bulk submissions have caused significant disruption to our team, and we will take the following actions:
+>
+> 1. **All uncoordinated bulk reports will be closed without review.**
+> 2. **Repeated offenders may be blocked** from further submissions.
+>
+> We welcome thorough security research, but please reach out before submitting multiple reports.
+
 ## Supported Versions
 
 We provide security updates for the following versions:
 
+
 | Version | Supported          |
 | ------- | ------------------ |
 | Latest  | :white_check_mark: |
 | Older   | :x:                |
 
+
 We strongly recommend that users always use the latest version for the best security and features.
 
 ## Reporting a Vulnerability
@@ -23,7 +33,7 @@ To report a security issue, please use the GitHub Security Advisories tab to "[O
 
 Alternatively, you can report via email:
 
-- **Email:** support@quantumnous.com
+- **Email:** [support@quantumnous.com](mailto:support@quantumnous.com)
 - **Subject:** `[SECURITY] Security Vulnerability Report`
 
 ### What to Include
@@ -83,4 +93,4 @@ For detailed configuration instructions, please refer to the project documentati
 
 ## Disclaimer
 
-This project is provided "as is" without any express or implied warranty. Users should assess the security risks of using this software in their environment.
+This project is provided "as is" without any express or implied warranty. Users should assess the security risks of using this software in their environment.

+ 141 - 0
.github/workflows/docker-build.yml

@@ -0,0 +1,141 @@
+name: Publish Docker image (Multi-arch)
+
+on:
+  push:
+    tags:
+      - '*'
+      - '!nightly*'
+  workflow_dispatch:
+    inputs:
+      tag:
+        description: 'Tag name to build (e.g., v0.10.8-alpha.3)'
+        required: true
+        type: string
+
+jobs:
+  build_single_arch:
+    name: Build & push (${{ matrix.arch }})
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - arch: amd64
+            platform: linux/amd64
+            runner: ubuntu-latest
+          - arch: arm64
+            platform: linux/arm64
+            runner: ubuntu-24.04-arm
+    runs-on: ${{ matrix.runner }}
+    outputs:
+      tag: ${{ steps.version.outputs.tag }}
+
+    permissions:
+      packages: write
+      contents: read
+      id-token: write
+
+    steps:
+      - name: Check out
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }}
+          ref: ${{ github.event.inputs.tag || github.ref }}
+
+      - name: Resolve tag & write VERSION
+        id: version
+        run: |
+          if [ -n "${{ github.event.inputs.tag }}" ]; then
+            TAG="${{ github.event.inputs.tag }}"
+            if ! git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then
+              echo "::error::Tag '$TAG' does not exist"
+              exit 1
+            fi
+          else
+            TAG=${GITHUB_REF#refs/tags/}
+          fi
+          echo "TAG=${TAG}" >> $GITHUB_ENV
+          echo "tag=${TAG}" >> $GITHUB_OUTPUT
+          echo "${TAG}" > VERSION
+          echo "Building tag: ${TAG} for ${{ matrix.arch }}"
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v3
+
+      - name: Log in to Docker Hub
+        uses: docker/login-action@v3
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+      - name: Extract metadata (labels)
+        id: meta
+        uses: docker/metadata-action@v5
+        with:
+          images: calciumion/new-api
+
+      - name: Build & push
+        id: build
+        uses: docker/build-push-action@v6
+        with:
+          context: .
+          platforms: ${{ matrix.platform }}
+          push: true
+          tags: |
+            calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }}
+            calciumion/new-api:latest-${{ matrix.arch }}
+          labels: ${{ steps.meta.outputs.labels }}
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
+          provenance: mode=max
+          sbom: true
+
+      - name: Install cosign
+        uses: sigstore/cosign-installer@v3
+
+      - name: Sign image with cosign
+        run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
+
+      - name: Image summary
+        run: |
+          echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY
+          echo '```' >> $GITHUB_STEP_SUMMARY
+          echo "calciumion/new-api:${TAG}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
+          echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY
+          echo '```' >> $GITHUB_STEP_SUMMARY
+
+  create_manifests:
+    name: Create multi-arch manifests
+    needs: [build_single_arch]
+    runs-on: ubuntu-latest
+    if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
+
+    steps:
+      - name: Set version
+        run: echo "TAG=${{ needs.build_single_arch.outputs.tag }}" >> $GITHUB_ENV
+
+      - name: Log in to Docker Hub
+        uses: docker/login-action@v3
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+      - name: Create & push manifest (version)
+        run: |
+          docker buildx imagetools create \
+            -t calciumion/new-api:${TAG} \
+            calciumion/new-api:${TAG}-amd64 \
+            calciumion/new-api:${TAG}-arm64
+
+      - name: Create & push manifest (latest)
+        run: |
+          docker buildx imagetools create \
+            -t calciumion/new-api:latest \
+            calciumion/new-api:latest-amd64 \
+            calciumion/new-api:latest-arm64
+
+      - name: Manifest summary
+        run: |
+          echo "### Multi-arch Manifest" >> $GITHUB_STEP_SUMMARY
+          echo '```' >> $GITHUB_STEP_SUMMARY
+          docker buildx imagetools inspect calciumion/new-api:${TAG} >> $GITHUB_STEP_SUMMARY
+          echo '```' >> $GITHUB_STEP_SUMMARY

+ 39 - 11
.github/workflows/docker-image-alpha.yml

@@ -27,9 +27,10 @@ jobs:
     permissions:
       packages: write
       contents: read
+      id-token: write
     steps:
       - name: Check out (shallow)
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           fetch-depth: 1
 
@@ -46,16 +47,16 @@ jobs:
         run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
 
       - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v3
+        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
 
       - name: Log in to Docker Hub
-        uses: docker/login-action@v3
+        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
 
       - name: Log in to GHCR
-        uses: docker/login-action@v3
+        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
         with:
           registry: ghcr.io
           username: ${{ github.actor }}
@@ -63,14 +64,15 @@ jobs:
 
       - name: Extract metadata (labels)
         id: meta
-        uses: docker/metadata-action@v5
+        uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
         with:
           images: |
             calciumion/new-api
             ghcr.io/${{ env.GHCR_REPOSITORY }}
 
       - name: Build & push single-arch (to both registries)
-        uses: docker/build-push-action@v6
+        id: build
+        uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
         with:
           context: .
           platforms: ${{ matrix.platform }}
@@ -83,8 +85,25 @@ jobs:
           labels: ${{ steps.meta.outputs.labels }}
           cache-from: type=gha
           cache-to: type=gha,mode=max
-          provenance: false
-          sbom: false
+          provenance: mode=max
+          sbom: true
+
+      - name: Install cosign
+        uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
+
+      - name: Sign image with cosign
+        run: |
+          cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
+          cosign sign --yes ghcr.io/${{ env.GHCR_REPOSITORY }}@${{ steps.build.outputs.digest }}
+
+      - name: Output digest
+        run: |
+          echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY
+          echo '```' >> $GITHUB_STEP_SUMMARY
+          echo "calciumion/new-api:alpha-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
+          echo "ghcr.io/${{ env.GHCR_REPOSITORY }}:alpha-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
+          echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY
+          echo '```' >> $GITHUB_STEP_SUMMARY
 
   create_manifests:
     name: Create multi-arch manifests (Docker Hub + GHCR)
@@ -95,7 +114,7 @@ jobs:
       contents: read
     steps:
       - name: Check out (shallow)
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           fetch-depth: 1
 
@@ -110,7 +129,7 @@ jobs:
           echo "VERSION=$VERSION" >> $GITHUB_ENV
 
       - name: Log in to Docker Hub
-        uses: docker/login-action@v3
+        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -130,7 +149,7 @@ jobs:
             calciumion/new-api:${VERSION}-arm64
 
       - name: Log in to GHCR
-        uses: docker/login-action@v3
+        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
         with:
           registry: ghcr.io
           username: ${{ github.actor }}
@@ -149,3 +168,12 @@ jobs:
             -t ghcr.io/${GHCR_REPOSITORY}:${VERSION} \
             ghcr.io/${GHCR_REPOSITORY}:${VERSION}-amd64 \
             ghcr.io/${GHCR_REPOSITORY}:${VERSION}-arm64
+
+      - name: Output manifest digest
+        run: |
+          echo "### Multi-arch Manifest Digests" >> $GITHUB_STEP_SUMMARY
+          echo '```' >> $GITHUB_STEP_SUMMARY
+          docker buildx imagetools inspect calciumion/new-api:alpha >> $GITHUB_STEP_SUMMARY
+          echo "---" >> $GITHUB_STEP_SUMMARY
+          docker buildx imagetools inspect ghcr.io/${GHCR_REPOSITORY}:alpha >> $GITHUB_STEP_SUMMARY
+          echo '```' >> $GITHUB_STEP_SUMMARY

+ 113 - 0
.github/workflows/docker-image-nightly.yml

@@ -0,0 +1,113 @@
+name: Publish Docker image (nightly)
+
+on:
+  push:
+    branches:
+      - nightly
+  workflow_dispatch:
+    inputs:
+      name:
+        description: "reason"
+        required: false
+
+jobs:
+  build_single_arch:
+    name: Build & push (${{ matrix.arch }}) [native]
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - arch: amd64
+            platform: linux/amd64
+            runner: ubuntu-latest
+          - arch: arm64
+            platform: linux/arm64
+            runner: ubuntu-24.04-arm
+    runs-on: ${{ matrix.runner }}
+
+    permissions:
+      contents: read
+
+    steps:
+      - name: Check out (shallow)
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 1
+
+      - name: Determine nightly version
+        id: version
+        run: |
+          VERSION="nightly-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
+          echo "$VERSION" > VERSION
+          echo "value=$VERSION" >> $GITHUB_OUTPUT
+          echo "VERSION=$VERSION" >> $GITHUB_ENV
+          echo "Publishing version: $VERSION for ${{ matrix.arch }}"
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v3
+
+      - name: Log in to Docker Hub
+        uses: docker/login-action@v3
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+      - name: Extract metadata (labels)
+        id: meta
+        uses: docker/metadata-action@v5
+        with:
+          images: |
+            calciumion/new-api
+
+      - name: Build & push single-arch
+        uses: docker/build-push-action@v6
+        with:
+          context: .
+          platforms: ${{ matrix.platform }}
+          push: true
+          tags: |
+            calciumion/new-api:nightly-${{ matrix.arch }}
+            calciumion/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }}
+          labels: ${{ steps.meta.outputs.labels }}
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
+          provenance: false
+          sbom: false
+
+  create_manifests:
+    name: Create multi-arch manifests (Docker Hub)
+    needs: [build_single_arch]
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Check out (shallow)
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 1
+
+      - name: Determine nightly version
+        id: version
+        run: |
+          VERSION="nightly-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
+          echo "value=$VERSION" >> $GITHUB_OUTPUT
+          echo "VERSION=$VERSION" >> $GITHUB_ENV
+
+      - name: Log in to Docker Hub
+        uses: docker/login-action@v3
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+      - name: Create & push manifest (Docker Hub - nightly)
+        run: |
+          docker buildx imagetools create \
+            -t calciumion/new-api:nightly \
+            calciumion/new-api:nightly-amd64 \
+            calciumion/new-api:nightly-arm64
+
+      - name: Create & push manifest (Docker Hub - versioned nightly)
+        run: |
+          docker buildx imagetools create \
+            -t calciumion/new-api:${VERSION} \
+            calciumion/new-api:${VERSION}-amd64 \
+            calciumion/new-api:${VERSION}-arm64

+ 33 - 0
.github/workflows/pr-check.yml

@@ -0,0 +1,33 @@
+name: PR Check
+
+permissions:
+  contents: read
+  issues: read
+  pull-requests: read
+
+on:
+  pull_request_target:
+    types: [opened, reopened]
+
+jobs:
+  pr-quality:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: peakoss/anti-slop@v0.2.1
+        with:
+          max-failures: 4
+          require-description: true
+
+          # require-linked-issue: false
+          blocked-terms:  |
+            🤖 Generated with Claude Code
+
+          require-pr-template: true
+          strict-pr-template-sections: "✅ 提交前检查项 / Checklist"
+
+          detect-spam-usernames: true
+          min-account-age: 30
+
+          failure-add-pr-labels: "pr-check-failed"
+          failure-pr-message: "感谢您的提交。由于该 PR 未遵循我们的贡献模板,且被识别为缺乏人工参与的纯 AI 生成内容 (AI Slop),我们将先予以关闭。我们更欢迎经过人工审核、验证并带有个人思考的贡献。如果您认为这其中存在误解,请回复告知。/ Thank you for your submission. This PR has been closed because it does not follow our contribution template and has been identified as purely AI-generated content (AI Slop) without meaningful human involvement. We prioritize contributions that are human-verified and reflect individual effort. If you believe this is a mistake, please let us know by replying to this comment."
+          close-pr: true

+ 61 - 23
.github/workflows/release.yml

@@ -19,26 +19,34 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
-        uses: actions/checkout@v3
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           fetch-depth: 0
       - name: Determine Version
         run: |
           VERSION=$(git describe --tags)
           echo "VERSION=$VERSION" >> $GITHUB_ENV
-      - uses: oven-sh/setup-bun@v2
+      - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
         with:
           bun-version: latest
-      - name: Build Frontend
+      - name: Build Frontend (default)
         env:
           CI: ""
         run: |
-          cd web
+          cd web/default
           bun install
           DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
-          cd ..
+          cd ../..
+      - name: Build Frontend (classic)
+        env:
+          CI: ""
+        run: |
+          cd web/classic
+          bun install
+          VITE_REACT_APP_VERSION=$VERSION bun run build
+          cd ../..
       - name: Set up Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
         with:
           go-version: '>=1.25.1'
       - name: Build Backend (amd64)
@@ -50,12 +58,16 @@ jobs:
           sudo apt-get update
           DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
           CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION
+      - name: Generate checksums
+        run: sha256sum new-api-* > checksums-linux.txt
+
       - name: Release
-        uses: softprops/action-gh-release@v2
+        uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
         if: startsWith(github.ref, 'refs/tags/')
         with:
           files: |
             new-api-*
+            checksums-linux.txt
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
@@ -64,38 +76,51 @@ jobs:
     runs-on: macos-latest
     steps:
       - name: Checkout
-        uses: actions/checkout@v3
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           fetch-depth: 0
       - name: Determine Version
         run: |
           VERSION=$(git describe --tags)
           echo "VERSION=$VERSION" >> $GITHUB_ENV
-      - uses: oven-sh/setup-bun@v2
+      - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
         with:
           bun-version: latest
-      - name: Build Frontend
+      - name: Build Frontend (default)
         env:
           CI: ""
           NODE_OPTIONS: "--max-old-space-size=4096"
         run: |
-          cd web
+          cd web/default
           bun install
           DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
-          cd ..
+          cd ../..
+      - name: Build Frontend (classic)
+        env:
+          CI: ""
+        run: |
+          cd web/classic
+          bun install
+          VITE_REACT_APP_VERSION=$VERSION bun run build
+          cd ../..
       - name: Set up Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
         with:
           go-version: '>=1.25.1'
       - name: Build Backend
         run: |
           go mod download
           go build -ldflags "-X 'new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION
+      - name: Generate checksums
+        run: shasum -a 256 new-api-macos-* > checksums-macos.txt
+
       - name: Release
-        uses: softprops/action-gh-release@v2
+        uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
         if: startsWith(github.ref, 'refs/tags/')
         with:
-          files: new-api-macos-*
+          files: |
+            new-api-macos-*
+            checksums-macos.txt
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
@@ -107,36 +132,49 @@ jobs:
         shell: bash
     steps:
       - name: Checkout
-        uses: actions/checkout@v3
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           fetch-depth: 0
       - name: Determine Version
         run: |
           VERSION=$(git describe --tags)
           echo "VERSION=$VERSION" >> $GITHUB_ENV
-      - uses: oven-sh/setup-bun@v2
+      - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
         with:
           bun-version: latest
-      - name: Build Frontend
+      - name: Build Frontend (default)
         env:
           CI: ""
         run: |
-          cd web
+          cd web/default
           bun install
           DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
-          cd ..
+          cd ../..
+      - name: Build Frontend (classic)
+        env:
+          CI: ""
+        run: |
+          cd web/classic
+          bun install
+          VITE_REACT_APP_VERSION=$VERSION bun run build
+          cd ../..
       - name: Set up Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
         with:
           go-version: '>=1.25.1'
       - name: Build Backend
         run: |
           go mod download
           go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe
+      - name: Generate checksums
+        run: sha256sum new-api-*.exe > checksums-windows.txt
+
       - name: Release
-        uses: softprops/action-gh-release@v2
+        uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
         if: startsWith(github.ref, 'refs/tags/')
         with:
-          files: new-api-*.exe
+          files: |
+            new-api-*.exe
+            checksums-windows.txt
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 9 - 1
.gitignore

@@ -1,12 +1,16 @@
 .idea
 .vscode
 .zed
+.history
 upload
 *.exe
 *.db
 build
 *.db-journal
 logs
+web/default/dist
+web/classic/dist
+web/node_modules
 web/dist
 .env
 one-api
@@ -18,8 +22,9 @@ tiktoken_cache
 .gocache
 .gomodcache/
 .cache
-web/bun.lock
 plans
+.claude
+.cursor
 
 electron/node_modules
 electron/dist
@@ -27,3 +32,6 @@ data/
 .gomodcache/
 .gocache-temp
 .gopath
+.test
+token_estimator_test.go
+skills-lock.json

+ 15 - 10
AGENTS.md

@@ -7,7 +7,7 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
 ## Tech Stack
 
 - **Backend**: Go 1.22+, Gin web framework, GORM v2 ORM
-- **Frontend**: React 18, Vite, Semi Design UI (@douyinfe/semi-ui)
+- **Frontend**: React 19, TypeScript, Rsbuild, Base UI, Tailwind CSS
 - **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
 - **Cache**: Redis (go-redis) + in-memory cache
 - **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.)
@@ -33,8 +33,10 @@ types/         — Type definitions (relay formats, file sources, errors)
 i18n/          — Backend internationalization (go-i18n, en/zh)
 oauth/         — OAuth provider implementations
 pkg/           — Internal packages (cachex, ionet)
-web/           — React frontend
-  web/src/i18n/  — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
+web/             — Frontend themes container
+ web/default/   — Default frontend (React 19, Rsbuild, Base UI, Tailwind)
+  web/classic/   — Classic frontend (React 18, Vite, Semi Design)
+  web/default/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
 ```
 
 ## Internationalization (i18n)
@@ -43,13 +45,12 @@ web/           — React frontend
 - Library: `nicksnyder/go-i18n/v2`
 - Languages: en, zh
 
-### Frontend (`web/src/i18n/`)
+### Frontend (`web/default/src/i18n/`)
 - Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
-- Languages: zh (fallback), en, fr, ru, ja, vi
-- Translation files: `web/src/i18n/locales/{lang}.json` — flat JSON, keys are Chinese source strings
-- Usage: `useTranslation()` hook, call `t('中文key')` in components
-- Semi UI locale synced via `SemiLocaleWrapper`
-- CLI tools: `bun run i18n:extract`, `bun run i18n:sync`, `bun run i18n:lint`
+- Languages: en (base), zh (fallback), fr, ru, ja, vi
+- Translation files: `web/default/src/i18n/locales/{lang}.json` — flat JSON, keys are English source strings
+- Usage: `useTranslation()` hook, call `t('English key')` in components
+- CLI tools: `bun run i18n:sync` (from `web/default/`)
 
 ## Rules
 
@@ -93,7 +94,7 @@ All database code MUST be fully compatible with all three databases simultaneous
 
 ### Rule 3: Frontend — Prefer Bun
 
-Use `bun` as the preferred package manager and script runner for the frontend (`web/` directory):
+Use `bun` as the preferred package manager and script runner for the frontend (`web/default/` directory):
 - `bun install` for dependency installation
 - `bun run dev` for development server
 - `bun run build` for production build
@@ -130,3 +131,7 @@ For request structs that are parsed from client JSON and then re-marshaled to up
   - field absent in client JSON => `nil` => omitted on marshal;
   - field explicitly set to zero/false => non-`nil` pointer => must still be sent upstream.
 - Avoid using non-pointer scalars with `omitempty` for optional request parameters, because zero values (`0`, `0.0`, `false`) will be silently dropped during marshal.
+
+### Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md`
+
+When working on tiered/dynamic billing (expression-based pricing), you MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language (variables, functions, examples), full system architecture (editor → storage → pre-consume → settlement → log display), token normalization rules (`p`/`c` auto-exclusion), quota conversion, and expression versioning. All code changes to the billing expression system must follow the patterns described in that document.

+ 15 - 10
CLAUDE.md

@@ -7,7 +7,7 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
 ## Tech Stack
 
 - **Backend**: Go 1.22+, Gin web framework, GORM v2 ORM
-- **Frontend**: React 18, Vite, Semi Design UI (@douyinfe/semi-ui)
+- **Frontend**: React 19, TypeScript, Rsbuild, Base UI, Tailwind CSS
 - **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
 - **Cache**: Redis (go-redis) + in-memory cache
 - **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.)
@@ -33,8 +33,10 @@ types/         — Type definitions (relay formats, file sources, errors)
 i18n/          — Backend internationalization (go-i18n, en/zh)
 oauth/         — OAuth provider implementations
 pkg/           — Internal packages (cachex, ionet)
-web/           — React frontend
-  web/src/i18n/  — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
+web/             — Frontend themes container
+ web/default/   — Default frontend (React 19, Rsbuild, Base UI, Tailwind)
+  web/classic/   — Classic frontend (React 18, Vite, Semi Design)
+  web/default/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
 ```
 
 ## Internationalization (i18n)
@@ -43,13 +45,12 @@ web/           — React frontend
 - Library: `nicksnyder/go-i18n/v2`
 - Languages: en, zh
 
-### Frontend (`web/src/i18n/`)
+### Frontend (`web/default/src/i18n/`)
 - Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
-- Languages: zh (fallback), en, fr, ru, ja, vi
-- Translation files: `web/src/i18n/locales/{lang}.json` — flat JSON, keys are Chinese source strings
-- Usage: `useTranslation()` hook, call `t('中文key')` in components
-- Semi UI locale synced via `SemiLocaleWrapper`
-- CLI tools: `bun run i18n:extract`, `bun run i18n:sync`, `bun run i18n:lint`
+- Languages: en (base), zh (fallback), fr, ru, ja, vi
+- Translation files: `web/default/src/i18n/locales/{lang}.json` — flat JSON, keys are English source strings
+- Usage: `useTranslation()` hook, call `t('English key')` in components
+- CLI tools: `bun run i18n:sync` (from `web/default/`)
 
 ## Rules
 
@@ -93,7 +94,7 @@ All database code MUST be fully compatible with all three databases simultaneous
 
 ### Rule 3: Frontend — Prefer Bun
 
-Use `bun` as the preferred package manager and script runner for the frontend (`web/` directory):
+Use `bun` as the preferred package manager and script runner for the frontend (`web/default/` directory):
 - `bun install` for dependency installation
 - `bun run dev` for development server
 - `bun run build` for production build
@@ -130,3 +131,7 @@ For request structs that are parsed from client JSON and then re-marshaled to up
   - field absent in client JSON => `nil` => omitted on marshal;
   - field explicitly set to zero/false => non-`nil` pointer => must still be sent upstream.
 - Avoid using non-pointer scalars with `omitempty` for optional request parameters, because zero values (`0`, `0.0`, `false`) will be silently dropped during marshal.
+
+### Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md`
+
+When working on tiered/dynamic billing (expression-based pricing), you MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language (variables, functions, examples), full system architecture (editor → storage → pre-consume → settlement → log display), token normalization rules (`p`/`c` auto-exclusion), quota conversion, and expression versioning. All code changes to the billing expression system must follow the patterns described in that document.

+ 18 - 7
Dockerfile

@@ -1,14 +1,24 @@
-FROM oven/bun:latest AS builder
+FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
 
 WORKDIR /build
-COPY web/package.json .
-COPY web/bun.lock .
+COPY web/default/package.json .
+COPY web/default/bun.lock .
 RUN bun install
-COPY ./web .
+COPY ./web/default .
 COPY ./VERSION .
 RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
 
-FROM golang:alpine AS builder2
+FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic
+
+WORKDIR /build
+COPY web/classic/package.json .
+COPY web/classic/bun.lock .
+RUN bun install
+COPY ./web/classic .
+COPY ./VERSION .
+RUN VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
+
+FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
 ENV GO111MODULE=on CGO_ENABLED=0
 
 ARG TARGETOS
@@ -22,10 +32,11 @@ ADD go.mod go.sum ./
 RUN go mod download
 
 COPY . .
-COPY --from=builder /build/dist ./web/dist
+COPY --from=builder /build/dist ./web/default/dist
+COPY --from=builder-classic /build/dist ./web/classic/dist
 RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
 
-FROM debian:bookworm-slim
+FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
 
 RUN apt-get update \
     && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \

+ 35 - 0
Dockerfile.dev

@@ -0,0 +1,35 @@
+# Backend-only build for frontend development
+# Skips frontend build, uses a placeholder for //go:embed web/dist
+
+FROM golang:1.26.1-alpine AS builder
+
+ENV GO111MODULE=on CGO_ENABLED=0
+ARG TARGETOS
+ARG TARGETARCH
+ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64}
+ENV GOEXPERIMENT=greenteagc
+
+WORKDIR /build
+
+ADD go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+
+RUN mkdir -p web/default/dist web/classic/dist && \
+    echo '<!doctype html><html><head><title>dev</title></head><body>use frontend dev server</body></html>' > web/default/dist/index.html && \
+    echo '<!doctype html><html><head><title>dev</title></head><body>use frontend dev server</body></html>' > web/classic/dist/index.html
+
+RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
+
+FROM debian:bookworm-slim
+
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends ca-certificates tzdata wget \
+    && rm -rf /var/lib/apt/lists/* \
+    && update-ca-certificates
+
+COPY --from=builder /build/new-api /
+EXPOSE 3000
+WORKDIR /data
+ENTRYPOINT ["/new-api"]

+ 459 - 0
README.en.md

@@ -0,0 +1,459 @@
+<div align="center">
+
+![new-api](/web/default/public/logo.png)
+
+# New API
+
+🍥 **Next-Generation Large Model Gateway and AI Asset Management System**
+
+<p align="center">
+  <a href="./README.md">中文</a> | 
+  <strong>English</strong> | 
+  <a href="./README.fr.md">Français</a> | 
+  <a href="./README.ja.md">日本語</a>
+</p>
+
+<p align="center">
+  <a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
+    <img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="license">
+  </a>
+  <a href="https://github.com/Calcium-Ion/new-api/releases/latest">
+    <img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="release">
+  </a>
+  <a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
+    <img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
+  </a>
+  <a href="https://hub.docker.com/r/CalciumIon/new-api">
+    <img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
+  </a>
+  <a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
+    <img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
+  </a>
+</p>
+
+<p align="center">
+  <a href="https://trendshift.io/repositories/8227" target="_blank">
+    <img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
+  </a>
+</p>
+
+<p align="center">
+  <a href="#-quick-start">Quick Start</a> •
+  <a href="#-key-features">Key Features</a> •
+  <a href="#-deployment">Deployment</a> •
+  <a href="#-documentation">Documentation</a> •
+  <a href="#-help-support">Help</a>
+</p>
+
+</div>
+
+## 📝 Project Description
+
+> [!NOTE]  
+> This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api)
+
+> [!IMPORTANT]  
+> - This project is for personal learning purposes only, with no guarantee of stability or technical support
+> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes
+> - According to the [《Interim Measures for the Management of Generative Artificial Intelligence Services》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), please do not provide any unregistered generative AI services to the public in China.
+
+---
+
+## 🤝 Trusted Partners
+
+<p align="center">
+  <em>No particular order</em>
+</p>
+
+<p align="center">
+  <a href="https://www.cherry-ai.com/" target="_blank">
+    <img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
+  </a>
+  <a href="https://bda.pku.edu.cn/" target="_blank">
+    <img src="./docs/images/pku.png" alt="Peking University" height="80" />
+  </a>
+  <a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
+    <img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
+  </a>
+  <a href="https://www.aliyun.com/" target="_blank">
+    <img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
+  </a>
+  <a href="https://io.net/" target="_blank">
+    <img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
+  </a>
+</p>
+
+---
+
+## 🙏 Special Thanks
+
+<p align="center">
+  <a href="https://www.jetbrains.com/?from=new-api" target="_blank">
+    <img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
+  </a>
+</p>
+
+<p align="center">
+  <strong>Thanks to <a href="https://www.jetbrains.com/?from=new-api">JetBrains</a> for providing free open-source development license for this project</strong>
+</p>
+
+---
+
+## 🚀 Quick Start
+
+### Using Docker Compose (Recommended)
+
+```bash
+# Clone the project
+git clone https://github.com/QuantumNous/new-api.git
+cd new-api
+
+# Edit docker-compose.yml configuration
+nano docker-compose.yml
+
+# Start the service
+docker-compose up -d
+```
+
+<details>
+<summary><strong>Using Docker Commands</strong></summary>
+
+```bash
+# Pull the latest image
+docker pull calciumion/new-api:latest
+
+# Using SQLite (default)
+docker run --name new-api -d --restart always \
+  -p 3000:3000 \
+  -e TZ=Asia/Shanghai \
+  -v ./data:/data \
+  calciumion/new-api:latest
+
+# Using MySQL
+docker run --name new-api -d --restart always \
+  -p 3000:3000 \
+  -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
+  -e TZ=Asia/Shanghai \
+  -v ./data:/data \
+  calciumion/new-api:latest
+```
+
+> **💡 Tip:** `-v ./data:/data` will save data in the `data` folder of the current directory, you can also change it to an absolute path like `-v /your/custom/path:/data`
+
+</details>
+
+---
+
+🎉 After deployment is complete, visit `http://localhost:3000` to start using!
+
+📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation)
+
+---
+
+## 📚 Documentation
+
+<div align="center">
+
+### 📖 [Official Documentation](https://docs.newapi.pro/en/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
+
+</div>
+
+**Quick Navigation:**
+
+| Category | Link |
+|------|------|
+| 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/en/docs/installation) |
+| ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) |
+| 📡 API Documentation | [API Documentation](https://docs.newapi.pro/en/docs/api) |
+| ❓ FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
+| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
+
+---
+
+## ✨ Key Features
+
+> For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/en/docs/guide/wiki/basic-concepts/features-introduction)
+
+### 🎨 Core Functions
+
+| Feature | Description |
+|------|------|
+| 🎨 New UI | Modern user interface design |
+| 🌍 Multi-language | Supports Chinese, English, French, Japanese |
+| 🔄 Data Compatibility | Fully compatible with the original One API database |
+| 📈 Data Dashboard | Visual console and statistical analysis |
+| 🔒 Permission Management | Token grouping, model restrictions, user management |
+
+### 💰 Payment and Billing
+
+- ✅ Online recharge (EPay, Stripe)
+- ✅ Pay-per-use model pricing
+- ✅ Cache billing support (OpenAI, Azure, DeepSeek, Claude, Qwen and all supported models)
+- ✅ Flexible billing policy configuration
+
+### 🔐 Authorization and Security
+
+- 😈 Discord authorization login
+- 🤖 LinuxDO authorization login
+- 📱 Telegram authorization login
+- 🔑 OIDC unified authentication
+
+### 🚀 Advanced Features
+
+**API Format Support:**
+- ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
+- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (including Azure)
+- ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
+- ⚡ [Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat)
+- 🔄 [Rerank Models](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina)
+
+**Intelligent Routing:**
+- ⚖️ Channel weighted random
+- 🔄 Automatic retry on failure
+- 🚦 User-level model rate limiting
+
+**Format Conversion:**
+- 🔄 **OpenAI Compatible ⇄ Claude Messages**
+- 🔄 **OpenAI Compatible → Google Gemini**
+- 🔄 **Google Gemini → OpenAI Compatible** - Text only, function calling not supported yet
+- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - In development
+- 🔄 **Thinking-to-content functionality**
+
+**Reasoning Effort Support:**
+
+<details>
+<summary>View detailed configuration</summary>
+
+**OpenAI series models:**
+- `o3-mini-high` - High reasoning effort
+- `o3-mini-medium` - Medium reasoning effort
+- `o3-mini-low` - Low reasoning effort
+- `gpt-5-high` - High reasoning effort
+- `gpt-5-medium` - Medium reasoning effort
+- `gpt-5-low` - Low reasoning effort
+
+**Claude thinking models:**
+- `claude-3-7-sonnet-20250219-thinking` - Enable thinking mode
+
+**Google Gemini series models:**
+- `gemini-2.5-flash-thinking` - Enable thinking mode
+- `gemini-2.5-flash-nothinking` - Disable thinking mode
+- `gemini-2.5-pro-thinking` - Enable thinking mode
+- `gemini-2.5-pro-thinking-128` - Enable thinking mode with thinking budget of 128 tokens
+- You can also append `-low`, `-medium`, or `-high` to any Gemini model name to request the corresponding reasoning effort (no extra thinking-budget suffix needed).
+
+</details>
+
+---
+
+## 🤖 Model Support
+
+> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/en/docs/api)
+
+| Model Type | Description | Documentation |
+|---------|------|------|
+| 🤖 OpenAI GPTs | gpt-4-gizmo-* series | - |
+| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://doc.newapi.pro/en/api/midjourney-proxy-image) |
+| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://doc.newapi.pro/en/api/suno-music) |
+| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) |
+| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) |
+| 🌐 Gemini | Google Gemini format | [Documentation](https://doc.newapi.pro/en/api/google-gemini-chat) |
+| 🔧 Dify | ChatFlow mode | - |
+| 🎯 Custom | Supports complete call address | - |
+
+### 📡 Supported Interfaces
+
+<details>
+<summary>View complete interface list</summary>
+
+- [Chat Interface (Chat Completions)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion)
+- [Response Interface (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
+- [Image Interface (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/v1-images-generations--post)
+- [Audio Interface (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
+- [Video Interface (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/create-video-generation)
+- [Embedding Interface (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/create-embedding)
+- [Rerank Interface (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank)
+- [Realtime Conversation (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session)
+- [Claude Chat](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
+- [Google Gemini Chat](https://doc.newapi.pro/en/api/google-gemini-chat)
+
+</details>
+
+---
+
+## 🚢 Deployment
+
+> [!TIP]
+> **Latest Docker image:** `calciumion/new-api:latest`
+
+### 📋 Deployment Requirements
+
+| Component | Requirement |
+|------|------|
+| **Local database** | SQLite (Docker must mount `/data` directory)|
+| **Remote database** | MySQL ≥ 5.7.8 or PostgreSQL ≥ 9.6 |
+| **Container engine** | Docker / Docker Compose |
+
+### ⚙️ Environment Variable Configuration
+
+<details>
+<summary>Common environment variable configuration</summary>
+
+| Variable Name | Description | Default Value |
+|--------|------|--------|
+| `SESSION_SECRET` | Session secret (required for multi-machine deployment) | - |
+| `CRYPTO_SECRET` | Encryption secret (required for Redis) | - |
+| `SQL_DSN` | Database connection string | - |
+| `REDIS_CONN_STRING` | Redis connection string | - |
+| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
+| `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` |
+| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` |
+| `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` |
+| `ERROR_LOG_ENABLED` | Error log switch | `false` |
+| `PYROSCOPE_URL` | Pyroscope server address | - |
+| `PYROSCOPE_APP_NAME` | Pyroscope application name | `new-api` |
+| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope basic auth user | - |
+| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope basic auth password | - |
+| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex sampling rate | `5` |
+| `PYROSCOPE_BLOCK_RATE` | Pyroscope block sampling rate | `5` |
+| `HOSTNAME` | Hostname tag for Pyroscope | `new-api` |
+
+📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables)
+
+</details>
+
+### 🔧 Deployment Methods
+
+<details>
+<summary><strong>Method 1: Docker Compose (Recommended)</strong></summary>
+
+```bash
+# Clone the project
+git clone https://github.com/QuantumNous/new-api.git
+cd new-api
+
+# Edit configuration
+nano docker-compose.yml
+
+# Start service
+docker-compose up -d
+```
+
+</details>
+
+<details>
+<summary><strong>Method 2: Docker Commands</strong></summary>
+
+**Using SQLite:**
+```bash
+docker run --name new-api -d --restart always \
+  -p 3000:3000 \
+  -e TZ=Asia/Shanghai \
+  -v ./data:/data \
+  calciumion/new-api:latest
+```
+
+**Using MySQL:**
+```bash
+docker run --name new-api -d --restart always \
+  -p 3000:3000 \
+  -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
+  -e TZ=Asia/Shanghai \
+  -v ./data:/data \
+  calciumion/new-api:latest
+```
+
+> **💡 Path explanation:** 
+> - `./data:/data` - Relative path, data saved in the data folder of the current directory
+> - You can also use absolute path, e.g.: `/your/custom/path:/data`
+
+</details>
+
+<details>
+<summary><strong>Method 3: BaoTa Panel</strong></summary>
+
+1. Install BaoTa Panel (≥ 9.2.0 version)
+2. Search for **New-API** in the application store
+3. One-click installation
+
+📖 [Tutorial with images](./docs/BT.md)
+
+</details>
+
+### ⚠️ Multi-machine Deployment Considerations
+
+> [!WARNING]
+> - **Must set** `SESSION_SECRET` - Otherwise login status inconsistent
+> - **Shared Redis must set** `CRYPTO_SECRET` - Otherwise data cannot be decrypted
+
+### 🔄 Channel Retry and Cache
+
+**Retry configuration:** `Settings → Operation Settings → General Settings → Failure Retry Count`
+
+**Cache configuration:**
+- `REDIS_CONN_STRING`: Redis cache (recommended)
+- `MEMORY_CACHE_ENABLED`: Memory cache
+
+---
+
+## 🔗 Related Projects
+
+### Upstream Projects
+
+| Project | Description |
+|------|------|
+| [One API](https://github.com/songquanpeng/one-api) | Original project base |
+| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourney interface support |
+
+### Supporting Tools
+
+| Project | Description |
+|------|------|
+| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key quota query tool |
+| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API high-performance optimized version |
+
+---
+
+## 💬 Help Support
+
+### 📖 Documentation Resources
+
+| Resource | Link |
+|------|------|
+| 📘 FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
+| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
+| 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/en/docs/support/feedback-issues) |
+| 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/en/docs) |
+
+### 🤝 Contribution Guide
+
+Welcome all forms of contribution!
+
+- 🐛 Report Bugs
+- 💡 Propose New Features
+- 📝 Improve Documentation
+- 🔧 Submit Code
+
+---
+
+## 🌟 Star History
+
+<div align="center">
+
+[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)
+
+</div>
+
+---
+
+<div align="center">
+
+### 💖 Thank you for using New API
+
+If this project is helpful to you, welcome to give us a ⭐️ Star!
+
+**[Official Documentation](https://docs.newapi.pro/en/docs)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)**
+
+<sub>Built with ❤️ by QuantumNous</sub>
+
+</div>

+ 1 - 1
README.fr.md

@@ -1,6 +1,6 @@
 <div align="center">
 
-![new-api](/web/public/logo.png)
+![new-api](/web/default/public/logo.png)
 
 # New API
 

+ 1 - 1
README.ja.md

@@ -1,6 +1,6 @@
 <div align="center">
 
-![new-api](/web/public/logo.png)
+![new-api](/web/default/public/logo.png)
 
 # New API
 

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 <div align="center">
 
-![new-api](/web/public/logo.png)
+![new-api](/web/default/public/logo.png)
 
 # New API
 

+ 2 - 2
README.zh_CN.md

@@ -1,6 +1,6 @@
 <div align="center">
 
-![new-api](/web/public/logo.png)
+![new-api](/web/default/public/logo.png)
 
 # New API
 
@@ -383,7 +383,7 @@ docker run --name new-api -d --restart always \
 2. 在应用商店搜索 **New-API**
 3. 一键安装
 
-📖 [图文教程](./docs/BT.md)
+📖 [图文教程](./docs/installation/BT.md)
 
 </details>
 

+ 12 - 9
README.zh_TW.md

@@ -1,6 +1,6 @@
 <div align="center">
 
-![new-api](/web/public/logo.png)
+![new-api](/web/default/public/logo.png)
 
 # New API
 
@@ -70,17 +70,20 @@
 <p align="center">
   <a href="https://www.cherry-ai.com/" target="_blank">
     <img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
-  </a>
-  <a href="https://bda.pku.edu.cn/" target="_blank">
+  </a><!--
+  --><a href="https://github.com/iOfficeAI/AionUi/" target="_blank">
+    <img src="./docs/images/aionui.png" alt="Aion UI" height="80" />
+  </a><!--
+  --><a href="https://bda.pku.edu.cn/" target="_blank">
     <img src="./docs/images/pku.png" alt="北京大學" height="80" />
-  </a>
-  <a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
+  </a><!--
+  --><a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
     <img src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="80" />
-  </a>
-  <a href="https://www.aliyun.com/" target="_blank">
+  </a><!--
+  --><a href="https://www.aliyun.com/" target="_blank">
     <img src="./docs/images/aliyun.png" alt="阿里雲" height="80" />
-  </a>
-  <a href="https://io.net/" target="_blank">
+  </a><!--
+  --><a href="https://io.net/" target="_blank">
     <img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
   </a>
 </p>

+ 27 - 1
common/constants.go

@@ -5,6 +5,7 @@ import (
 	//"os"
 	//"strconv"
 	"sync"
+	"sync/atomic"
 	"time"
 
 	"github.com/google/uuid"
@@ -12,11 +13,29 @@ import (
 
 var StartTime = time.Now().Unix() // unit: second
 var Version = "v0.0.0"            // this hard coding will be replaced automatically when building, no need to manually change
-var SystemName = "AIRouter"
+var SystemName = "New API"
 var Footer = ""
 var Logo = ""
 var TopUpLink = ""
 
+var themeValue atomic.Value // stores string; safe for concurrent read/write
+
+func init() {
+	themeValue.Store("classic")
+}
+
+func GetTheme() string {
+	return themeValue.Load().(string)
+}
+
+// SetTheme updates the frontend theme atomically.
+// Only "default" and "classic" are accepted; other values are silently ignored.
+func SetTheme(t string) {
+	if t == "default" || t == "classic" {
+		themeValue.Store(t)
+	}
+}
+
 // var ChatLink = ""
 // var ChatLink2 = ""
 var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
@@ -80,6 +99,7 @@ var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true}
 var SMTPServer = ""
 var SMTPPort = 587
 var SMTPSSLEnabled = false
+var SMTPForceAuthLogin = false
 var SMTPAccount = ""
 var SMTPFrom = ""
 var SMTPToken = ""
@@ -115,6 +135,10 @@ var RetryTimes = 0
 
 var IsMasterNode bool
 
+// NodeName 节点名称,从 NODE_NAME 环境变量读取;
+// 用于审计日志中标识节点身份,在容器/K8s 部署时比自动探测到的容器内网 IP 更具可读性。
+var NodeName = ""
+
 var requestInterval int
 var RequestInterval time.Duration
 
@@ -177,6 +201,7 @@ var (
 	DownloadRateLimitDuration int64 = 60
 
 	// Per-user search rate limit (applies after authentication, keyed by user ID)
+	SearchRateLimitEnable         = true
 	SearchRateLimitNum            = 10
 	SearchRateLimitDuration int64 = 60
 )
@@ -211,5 +236,6 @@ const (
 const (
 	TopUpStatusPending = "pending"
 	TopUpStatusSuccess = "success"
+	TopUpStatusFailed  = "failed"
 	TopUpStatusExpired = "expired"
 )

+ 15 - 4
common/email.go

@@ -19,6 +19,20 @@ func generateMessageID() (string, error) {
 	return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil
 }
 
+func shouldUseSMTPLoginAuth() bool {
+	if SMTPForceAuthLogin {
+		return true
+	}
+	return isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer)
+}
+
+func getSMTPAuth() smtp.Auth {
+	if shouldUseSMTPLoginAuth() {
+		return LoginAuth(SMTPAccount, SMTPToken)
+	}
+	return smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
+}
+
 func SendEmail(subject string, receiver string, content string) error {
 	if SMTPFrom == "" { // for compatibility
 		SMTPFrom = SMTPAccount
@@ -38,7 +52,7 @@ func SendEmail(subject string, receiver string, content string) error {
 		"Message-ID: %s\r\n"+ // 添加 Message-ID 头
 		"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
 		receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), id, content))
-	auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
+	auth := getSMTPAuth()
 	addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
 	to := strings.Split(receiver, ";")
 	var err error
@@ -80,9 +94,6 @@ func SendEmail(subject string, receiver string, content string) error {
 		if err != nil {
 			return err
 		}
-	} else if isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer) {
-		auth = LoginAuth(SMTPAccount, SMTPToken)
-		err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
 	} else {
 		err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
 	}

+ 26 - 0
common/embed-file-system.go

@@ -41,3 +41,29 @@ func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
 		FileSystem: http.FS(efs),
 	}
 }
+
+// themeAwareFileSystem delegates to the appropriate embedded FS based on
+// the current theme (via GetTheme). This enables runtime theme switching
+// without restarting the server.
+type themeAwareFileSystem struct {
+	defaultFS static.ServeFileSystem
+	classicFS static.ServeFileSystem
+}
+
+func (t *themeAwareFileSystem) Exists(prefix string, path string) bool {
+	if GetTheme() == "classic" {
+		return t.classicFS.Exists(prefix, path)
+	}
+	return t.defaultFS.Exists(prefix, path)
+}
+
+func (t *themeAwareFileSystem) Open(name string) (http.File, error) {
+	if GetTheme() == "classic" {
+		return t.classicFS.Open(name)
+	}
+	return t.defaultFS.Open(name)
+}
+
+func NewThemeAwareFS(defaultFS, classicFS static.ServeFileSystem) static.ServeFileSystem {
+	return &themeAwareFileSystem{defaultFS: defaultFS, classicFS: classicFS}
+}

+ 1 - 0
common/gin.go

@@ -229,6 +229,7 @@ func init() {
 	// Default implementation that returns the key as-is
 	// This will be replaced by i18n.T during i18n initialization
 	TranslateMessage = func(c *gin.Context, key string, args ...map[string]any) string {
+		c.Header("X-Translate-id", "d5e7afdfc7f03414b941f9c1e7096be9966510e7")
 		return key
 	}
 }

+ 6 - 1
common/init.go

@@ -82,6 +82,7 @@ func InitEnv() {
 	DebugEnabled = os.Getenv("DEBUG") == "true"
 	MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true"
 	IsMasterNode = os.Getenv("NODE_TYPE") != "slave"
+	NodeName = os.Getenv("NODE_NAME")
 	TLSInsecureSkipVerify = GetEnvOrDefaultBool("TLS_INSECURE_SKIP_VERIFY", false)
 	if TLSInsecureSkipVerify {
 		if tr, ok := http.DefaultTransport.(*http.Transport); ok && tr != nil {
@@ -120,6 +121,10 @@ func InitEnv() {
 	CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
 	CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20)
 	CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60))
+
+	SearchRateLimitEnable = GetEnvOrDefaultBool("SEARCH_RATE_LIMIT_ENABLE", true)
+	SearchRateLimitNum = GetEnvOrDefault("SEARCH_RATE_LIMIT", 10)
+	SearchRateLimitDuration = int64(GetEnvOrDefault("SEARCH_RATE_LIMIT_DURATION", 60))
 	initConstantEnv()
 }
 
@@ -127,7 +132,7 @@ func initConstantEnv() {
 	constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300)
 	constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
 	constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 64)
-	constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 64)
+	constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 128)
 	// MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨
 	constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 128)
 	// ForceStreamOption 覆盖请求参数,强制返回usage信息

+ 16 - 0
common/json.go

@@ -43,3 +43,19 @@ func GetJsonType(data json.RawMessage) string {
 		return "number"
 	}
 }
+
+// JsonRawMessageToString returns JSON strings as their decoded value and other JSON values as raw text.
+func JsonRawMessageToString(data json.RawMessage) string {
+	trimmed := bytes.TrimSpace(data)
+	if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
+		return ""
+	}
+	if trimmed[0] != '"' {
+		return string(trimmed)
+	}
+	var value string
+	if err := Unmarshal(trimmed, &value); err != nil {
+		return string(trimmed)
+	}
+	return value
+}

+ 43 - 0
common/json_test.go

@@ -0,0 +1,43 @@
+package common
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestJsonRawMessageToString(t *testing.T) {
+	tests := []struct {
+		name string
+		data json.RawMessage
+		want string
+	}{
+		{
+			name: "object",
+			data: json.RawMessage(`{"city":"Paris","days":0,"strict":false}`),
+			want: `{"city":"Paris","days":0,"strict":false}`,
+		},
+		{
+			name: "string",
+			data: json.RawMessage(`"{\"city\":\"Paris\",\"days\":0,\"strict\":false}"`),
+			want: `{"city":"Paris","days":0,"strict":false}`,
+		},
+		{
+			name: "null",
+			data: json.RawMessage(`null`),
+			want: "",
+		},
+		{
+			name: "empty",
+			data: nil,
+			want: "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			require.Equal(t, tt.want, JsonRawMessageToString(tt.data))
+		})
+	}
+}

+ 71 - 27
common/ssrf_protection.go

@@ -29,45 +29,89 @@ var DefaultSSRFProtection = &SSRFProtection{
 	AllowedPorts:     []int{},
 }
 
-// isPrivateIP 检查IP是否为私有地址
+// privateIPv4Nets IPv4 私有/保留/特殊用途网段
+// 参考 IANA IPv4 Special-Purpose Address Registry
+// https://www.iana.org/assignments/iana-ipv4-special-registry/
+var privateIPv4Nets = []net.IPNet{
+	{IP: net.IPv4(0, 0, 0, 0), Mask: net.CIDRMask(8, 32)},       // 0.0.0.0/8 ("This network" / 未指定)
+	{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)},      // 10.0.0.0/8 (私有)
+	{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)},   // 100.64.0.0/10 (运营商级 NAT / CGNAT)
+	{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)},     // 127.0.0.0/8 (回环)
+	{IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)},  // 169.254.0.0/16 (链路本地)
+	{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)},   // 172.16.0.0/12 (私有)
+	{IP: net.IPv4(192, 0, 0, 0), Mask: net.CIDRMask(24, 32)},    // 192.0.0.0/24 (IETF 协议分配)
+	{IP: net.IPv4(192, 0, 2, 0), Mask: net.CIDRMask(24, 32)},    // 192.0.2.0/24 (TEST-NET-1)
+	{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)},  // 192.168.0.0/16 (私有)
+	{IP: net.IPv4(198, 18, 0, 0), Mask: net.CIDRMask(15, 32)},   // 198.18.0.0/15 (基准测试)
+	{IP: net.IPv4(198, 51, 100, 0), Mask: net.CIDRMask(24, 32)}, // 198.51.100.0/24 (TEST-NET-2)
+	{IP: net.IPv4(203, 0, 113, 0), Mask: net.CIDRMask(24, 32)},  // 203.0.113.0/24 (TEST-NET-3)
+	{IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)},     // 224.0.0.0/4 (组播)
+	{IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)},     // 240.0.0.0/4 (保留)
+	{IP: net.IPv4(255, 255, 255, 255), Mask: net.CIDRMask(32, 32)}, // 255.255.255.255/32 (受限广播)
+}
+
+// privateIPv6Nets IPv6 私有/保留/特殊用途网段
+// 参考 IANA IPv6 Special-Purpose Address Registry
+// https://www.iana.org/assignments/iana-ipv6-special-registry/
+var privateIPv6Nets = func() []net.IPNet {
+	cidrs := []string{
+		"::/128",        // 未指定地址
+		"::1/128",       // 回环
+		"::ffff:0:0/96", // IPv4-mapped
+		"64:ff9b::/96",  // IPv4/IPv6 translation
+		"100::/64",      // Discard-Only
+		"2001::/23",     // IETF Protocol Assignments
+		"2001:db8::/32", // 文档
+		"fc00::/7",      // Unique Local Address (ULA)
+		"fe80::/10",     // 链路本地
+		"ff00::/8",      // 组播
+	}
+	nets := make([]net.IPNet, 0, len(cidrs))
+	for _, c := range cidrs {
+		if _, n, err := net.ParseCIDR(c); err == nil && n != nil {
+			nets = append(nets, *n)
+		}
+	}
+	return nets
+}()
+
+// isPrivateIP 检查IP是否为私有/保留/特殊用途地址
 func isPrivateIP(ip net.IP) bool {
+	if ip == nil {
+		return true
+	}
+	// 未指定地址 (0.0.0.0, ::)
+	if ip.IsUnspecified() {
+		return true
+	}
+	// 回环、链路本地 (unicast/multicast)
 	if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
 		return true
 	}
-
-	// 检查私有网段
-	private := []net.IPNet{
-		{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)},     // 10.0.0.0/8
-		{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)},  // 172.16.0.0/12
-		{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
-		{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)},    // 127.0.0.0/8
-		{IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地)
-		{IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)},    // 224.0.0.0/4 (组播)
-		{IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)},    // 240.0.0.0/4 (保留)
+	// 接口本地组播 (IPv6 ff01::/16 等)
+	if ip.IsInterfaceLocalMulticast() {
+		return true
 	}
 
-	for _, privateNet := range private {
-		if privateNet.Contains(ip) {
-			return true
+	if v4 := ip.To4(); v4 != nil {
+		for _, privateNet := range privateIPv4Nets {
+			if privateNet.Contains(v4) {
+				return true
+			}
 		}
+		return false
 	}
 
-	// 检查IPv6私有地址
-	if ip.To4() == nil {
-		// IPv6 loopback
-		if ip.Equal(net.IPv6loopback) {
-			return true
-		}
-		// IPv6 link-local
-		if strings.HasPrefix(ip.String(), "fe80:") {
-			return true
-		}
-		// IPv6 unique local
-		if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") {
+	// IPv6 检查
+	for _, privateNet := range privateIPv6Nets {
+		if privateNet.Contains(ip) {
 			return true
 		}
 	}
-
+	// 兜底: Go 标准库识别的其他私有地址
+	if ip.IsPrivate() {
+		return true
+	}
 	return false
 }
 

+ 15 - 8
common/sys_log.go

@@ -3,53 +3,60 @@ package common
 import (
 	"fmt"
 	"os"
+	"sync"
 	"time"
 
 	"github.com/gin-gonic/gin"
 )
 
+// LogWriterMu protects concurrent access to gin.DefaultWriter/gin.DefaultErrorWriter
+// during log file rotation. Acquire RLock when reading/writing through the writers,
+// acquire Lock when swapping writers and closing old files.
+var LogWriterMu sync.RWMutex
+
 func SysLog(s string) {
 	t := time.Now()
+	LogWriterMu.RLock()
 	_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
+	LogWriterMu.RUnlock()
 }
 
 func SysError(s string) {
 	t := time.Now()
+	LogWriterMu.RLock()
 	_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
+	LogWriterMu.RUnlock()
 }
 
 func FatalLog(v ...any) {
 	t := time.Now()
+	LogWriterMu.RLock()
 	_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
+	LogWriterMu.RUnlock()
 	os.Exit(1)
 }
 
 func LogStartupSuccess(startTime time.Time, port string) {
-
 	duration := time.Since(startTime)
 	durationMs := duration.Milliseconds()
 
 	// Get network IPs
 	networkIps := GetNetworkIps()
 
-	// Print blank line for spacing
-	fmt.Fprintf(gin.DefaultWriter, "\n")
+	LogWriterMu.RLock()
+	defer LogWriterMu.RUnlock()
 
-	// Print the main success message
+	fmt.Fprintf(gin.DefaultWriter, "\n")
 	fmt.Fprintf(gin.DefaultWriter, "  \033[32m%s %s\033[0m  ready in %d ms\n", SystemName, Version, durationMs)
 	fmt.Fprintf(gin.DefaultWriter, "\n")
 
-	// Skip fancy startup message in container environments
 	if !IsRunningInContainer() {
-		// Print local URL
 		fmt.Fprintf(gin.DefaultWriter, "  ➜  \033[1mLocal:\033[0m   http://localhost:%s/\n", port)
 	}
 
-	// Print network URLs
 	for _, ip := range networkIps {
 		fmt.Fprintf(gin.DefaultWriter, "  ➜  \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port)
 	}
 
-	// Print blank line for spacing
 	fmt.Fprintf(gin.DefaultWriter, "\n")
 }

+ 1 - 0
constant/context_key.go

@@ -65,4 +65,5 @@ const (
 
 	// ContextKeyLanguage stores the user's language preference for i18n
 	ContextKeyLanguage ContextKey = "language"
+	ContextKeyIsStream ContextKey = "is_stream"
 )

+ 16 - 0
constant/waffo_pay_method.go

@@ -0,0 +1,16 @@
+package constant
+
+// WaffoPayMethod defines the display and API parameter mapping for Waffo payment methods.
+type WaffoPayMethod struct {
+	Name          string `json:"name"`            // Frontend display name
+	Icon          string `json:"icon"`            // Frontend icon identifier: credit-card, apple, google
+	PayMethodType string `json:"payMethodType"` // Waffo API PayMethodType, can be comma-separated
+	PayMethodName string `json:"payMethodName"` // Waffo API PayMethodName, empty means auto-select by Waffo checkout
+}
+
+// DefaultWaffoPayMethods is the default list of supported payment methods.
+var DefaultWaffoPayMethods = []WaffoPayMethod{
+	{Name: "Card", Icon: "/pay-card.png", PayMethodType: "CREDITCARD,DEBITCARD", PayMethodName: ""},
+	{Name: "Apple Pay", Icon: "/pay-apple.png", PayMethodType: "APPLEPAY", PayMethodName: "APPLEPAY"},
+	{Name: "Google Pay", Icon: "/pay-google.png", PayMethodType: "GOOGLEPAY", PayMethodName: "GOOGLEPAY"},
+}

+ 107 - 21
controller/channel-test.go

@@ -20,6 +20,7 @@ import (
 	"github.com/QuantumNous/new-api/dto"
 	"github.com/QuantumNous/new-api/middleware"
 	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/pkg/billingexpr"
 	"github.com/QuantumNous/new-api/relay"
 	relaycommon "github.com/QuantumNous/new-api/relay/common"
 	relayconstant "github.com/QuantumNous/new-api/relay/constant"
@@ -150,6 +151,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
 		}
 	}
 	cache.WriteContext(c)
+	c.Set("id", 1)
 
 	//c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
 	c.Request.Header.Set("Content-Type", "application/json")
@@ -232,6 +234,15 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
 	info.IsChannelTest = true
 	info.InitChannelMeta(c)
 
+	err = attachTestBillingRequestInput(info, request)
+	if err != nil {
+		return testResult{
+			context:     c,
+			localErr:    err,
+			newAPIError: types.NewError(err, types.ErrorCodeJsonMarshalFailed),
+		}
+	}
+
 	err = helper.ModelMappedHelper(c, info, request)
 	if err != nil {
 		return testResult{
@@ -274,7 +285,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
 		return testResult{
 			context:     c,
 			localErr:    err,
-			newAPIError: types.NewError(err, types.ErrorCodeModelPriceError),
+			newAPIError: types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest)),
 		}
 	}
 
@@ -459,7 +470,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
 			newAPIError: types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError),
 		}
 	}
-	if bodyErr := detectErrorFromTestResponseBody(respBody); bodyErr != nil {
+	if bodyErr := validateTestResponseBody(respBody, isStream); bodyErr != nil {
 		return testResult{
 			context:     c,
 			localErr:    bodyErr,
@@ -468,21 +479,11 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
 	}
 	info.SetEstimatePromptTokens(usage.PromptTokens)
 
-	quota := 0
-	if !priceData.UsePrice {
-		quota = usage.PromptTokens + int(math.Round(float64(usage.CompletionTokens)*priceData.CompletionRatio))
-		quota = int(math.Round(float64(quota) * priceData.ModelRatio))
-		if priceData.ModelRatio != 0 && quota <= 0 {
-			quota = 1
-		}
-	} else {
-		quota = int(priceData.ModelPrice * common.QuotaPerUnit)
-	}
+	quota, tieredResult := settleTestQuota(info, priceData, usage)
 	tok := time.Now()
 	milliseconds := tok.Sub(tik).Milliseconds()
 	consumedTime := float64(milliseconds) / 1000.0
-	other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio,
-		usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
+	other := buildTestLogOther(c, info, priceData, usage, tieredResult)
 	model.RecordConsumeLog(c, 1, model.RecordConsumeLogParams{
 		ChannelId:        channel.Id,
 		PromptTokens:     usage.PromptTokens,
@@ -504,6 +505,50 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
 	}
 }
 
+func attachTestBillingRequestInput(info *relaycommon.RelayInfo, request dto.Request) error {
+	if info == nil {
+		return nil
+	}
+
+	input, err := helper.BuildBillingExprRequestInputFromRequest(request, info.RequestHeaders)
+	if err != nil {
+		return err
+	}
+	info.BillingRequestInput = &input
+	return nil
+}
+
+func settleTestQuota(info *relaycommon.RelayInfo, priceData types.PriceData, usage *dto.Usage) (int, *billingexpr.TieredResult) {
+	if usage != nil && info != nil && info.TieredBillingSnapshot != nil {
+		isClaudeUsageSemantic := usage.UsageSemantic == "anthropic" || info.GetFinalRequestRelayFormat() == types.RelayFormatClaude
+		usedVars := billingexpr.UsedVars(info.TieredBillingSnapshot.ExprString)
+		if ok, quota, result := service.TryTieredSettle(info, service.BuildTieredTokenParams(usage, isClaudeUsageSemantic, usedVars)); ok {
+			return quota, result
+		}
+	}
+
+	quota := 0
+	if !priceData.UsePrice {
+		quota = usage.PromptTokens + int(math.Round(float64(usage.CompletionTokens)*priceData.CompletionRatio))
+		quota = int(math.Round(float64(quota) * priceData.ModelRatio))
+		if priceData.ModelRatio != 0 && quota <= 0 {
+			quota = 1
+		}
+		return quota, nil
+	}
+
+	return int(priceData.ModelPrice * common.QuotaPerUnit), nil
+}
+
+func buildTestLogOther(c *gin.Context, info *relaycommon.RelayInfo, priceData types.PriceData, usage *dto.Usage, tieredResult *billingexpr.TieredResult) map[string]interface{} {
+	other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio,
+		usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
+	if tieredResult != nil {
+		service.InjectTieredBillingInfo(other, info, tieredResult)
+	}
+	return other
+}
+
 func coerceTestUsage(usageAny any, isStream bool, estimatePromptTokens int) (*dto.Usage, error) {
 	switch u := usageAny.(type) {
 	case *dto.Usage:
@@ -569,6 +614,42 @@ func detectErrorFromTestResponseBody(respBody []byte) error {
 	return nil
 }
 
+func validateStreamTestResponseBody(respBody []byte) error {
+	b := bytes.TrimSpace(respBody)
+	if len(b) == 0 {
+		return errors.New("stream response body is empty")
+	}
+
+	for _, line := range bytes.Split(b, []byte{'\n'}) {
+		line = bytes.TrimSpace(line)
+		if len(line) == 0 || !bytes.HasPrefix(line, []byte("data:")) {
+			continue
+		}
+		payload := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:")))
+		if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) {
+			continue
+		}
+
+		return nil
+	}
+
+	return errors.New("stream response body does not contain a valid stream event")
+}
+
+func validateTestResponseBody(respBody []byte, isStream bool) error {
+	if bodyErr := detectErrorFromTestResponseBody(respBody); bodyErr != nil {
+		return bodyErr
+	}
+	if isStream {
+		return validateStreamTestResponseBody(respBody)
+	}
+	return nil
+}
+
+func shouldUseStreamForAutomaticChannelTest(channel *model.Channel) bool {
+	return channel != nil && channel.Type == constant.ChannelTypeCodex
+}
+
 func detectErrorMessageFromJSONBytes(jsonBytes []byte) string {
 	if len(jsonBytes) == 0 {
 		return ""
@@ -756,11 +837,15 @@ func TestChannel(c *gin.Context) {
 	tik := time.Now()
 	result := testChannel(channel, testModel, endpointType, isStream)
 	if result.localErr != nil {
-		c.JSON(http.StatusOK, gin.H{
+		resp := gin.H{
 			"success": false,
 			"message": result.localErr.Error(),
 			"time":    0.0,
-		})
+		}
+		if result.newAPIError != nil {
+			resp["error_code"] = result.newAPIError.GetErrorCode()
+		}
+		c.JSON(http.StatusOK, resp)
 		return
 	}
 	tok := time.Now()
@@ -769,9 +854,10 @@ func TestChannel(c *gin.Context) {
 	consumedTime := float64(milliseconds) / 1000.0
 	if result.newAPIError != nil {
 		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": result.newAPIError.Error(),
-			"time":    consumedTime,
+			"success":    false,
+			"message":    result.newAPIError.Error(),
+			"time":       consumedTime,
+			"error_code": result.newAPIError.GetErrorCode(),
 		})
 		return
 	}
@@ -816,7 +902,7 @@ func testAllChannels(notify bool) error {
 			}
 			isChannelEnabled := channel.Status == common.ChannelStatusEnabled
 			tik := time.Now()
-			result := testChannel(channel, "", "", false)
+			result := testChannel(channel, "", "", shouldUseStreamForAutomaticChannelTest(channel))
 			tok := time.Now()
 			milliseconds := tok.Sub(tik).Milliseconds()
 
@@ -824,7 +910,7 @@ func testAllChannels(notify bool) error {
 			newAPIError := result.newAPIError
 			// request error disables the channel
 			if newAPIError != nil {
-				shouldBanChannel = service.ShouldDisableChannel(channel.Type, result.newAPIError)
+				shouldBanChannel = service.ShouldDisableChannel(result.newAPIError)
 			}
 
 			// 当错误检查通过,才检查响应时间

+ 10 - 9
controller/channel.go

@@ -13,6 +13,7 @@ import (
 	"github.com/QuantumNous/new-api/constant"
 	"github.com/QuantumNous/new-api/dto"
 	"github.com/QuantumNous/new-api/model"
+	relaychannel "github.com/QuantumNous/new-api/relay/channel"
 	"github.com/QuantumNous/new-api/relay/channel/gemini"
 	"github.com/QuantumNous/new-api/relay/channel/ollama"
 	"github.com/QuantumNous/new-api/service"
@@ -71,6 +72,7 @@ func GetAllChannels(c *gin.Context) {
 	pageInfo := common.GetPageQuery(c)
 	channelData := make([]*model.Channel, 0)
 	idSort, _ := strconv.ParseBool(c.Query("id_sort"))
+	sortOptions := model.NewChannelSortOptions(c.Query("sort_by"), c.Query("sort_order"), idSort)
 	enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
 	statusParam := c.Query("status")
 	// statusFilter: -1 all, 1 enabled, 0 disabled (include auto & manual)
@@ -97,7 +99,7 @@ func GetAllChannels(c *gin.Context) {
 			if tag == nil || *tag == "" {
 				continue
 			}
-			tagChannels, err := model.GetChannelsByTag(*tag, idSort, false)
+			tagChannels, err := model.GetChannelsByTag(*tag, idSort, false, sortOptions)
 			if err != nil {
 				continue
 			}
@@ -130,12 +132,7 @@ func GetAllChannels(c *gin.Context) {
 
 		baseQuery.Count(&total)
 
-		order := "priority desc"
-		if idSort {
-			order = "id desc"
-		}
-
-		err := baseQuery.Order(order).Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("key").Find(&channelData).Error
+		err := sortOptions.Apply(baseQuery).Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("key").Find(&channelData).Error
 		if err != nil {
 			common.SysError("failed to get channels: " + err.Error())
 			c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道列表失败,请稍后重试"})
@@ -183,6 +180,9 @@ func buildFetchModelsHeaders(channel *model.Channel, key string) (http.Header, e
 
 	headerOverride := channel.GetHeaderOverride()
 	for k, v := range headerOverride {
+		if relaychannel.IsHeaderPassthroughRuleKey(k) {
+			continue
+		}
 		str, ok := v.(string)
 		if !ok {
 			return nil, fmt.Errorf("invalid header override for key %s", k)
@@ -248,6 +248,7 @@ func SearchChannels(c *gin.Context) {
 	statusParam := c.Query("status")
 	statusFilter := parseStatusFilter(statusParam)
 	idSort, _ := strconv.ParseBool(c.Query("id_sort"))
+	sortOptions := model.NewChannelSortOptions(c.Query("sort_by"), c.Query("sort_order"), idSort)
 	enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
 	channelData := make([]*model.Channel, 0)
 	if enableTagMode {
@@ -261,14 +262,14 @@ func SearchChannels(c *gin.Context) {
 		}
 		for _, tag := range tags {
 			if tag != nil && *tag != "" {
-				tagChannel, err := model.GetChannelsByTag(*tag, idSort, false)
+				tagChannel, err := model.GetChannelsByTag(*tag, idSort, false, sortOptions)
 				if err == nil {
 					channelData = append(channelData, tagChannel...)
 				}
 			}
 		}
 	} else {
-		channels, err := model.SearchChannels(keyword, group, modelKeyword, idSort)
+		channels, err := model.SearchChannels(keyword, group, modelKeyword, idSort, sortOptions)
 		if err != nil {
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,

+ 71 - 0
controller/channel_test_internal_test.go

@@ -0,0 +1,71 @@
+package controller
+
+import (
+	"net/http/httptest"
+	"testing"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/dto"
+	"github.com/QuantumNous/new-api/pkg/billingexpr"
+	relaycommon "github.com/QuantumNous/new-api/relay/common"
+	"github.com/QuantumNous/new-api/types"
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/require"
+)
+
+func TestSettleTestQuotaUsesTieredBilling(t *testing.T) {
+	info := &relaycommon.RelayInfo{
+		TieredBillingSnapshot: &billingexpr.BillingSnapshot{
+			BillingMode:   "tiered_expr",
+			ExprString:    `param("stream") == true ? tier("stream", p * 3) : tier("base", p * 2)`,
+			ExprHash:      billingexpr.ExprHashString(`param("stream") == true ? tier("stream", p * 3) : tier("base", p * 2)`),
+			GroupRatio:    1,
+			EstimatedTier: "stream",
+			QuotaPerUnit:  common.QuotaPerUnit,
+			ExprVersion:   1,
+		},
+		BillingRequestInput: &billingexpr.RequestInput{
+			Body: []byte(`{"stream":true}`),
+		},
+	}
+
+	quota, result := settleTestQuota(info, types.PriceData{
+		ModelRatio:      1,
+		CompletionRatio: 2,
+	}, &dto.Usage{
+		PromptTokens: 1000,
+	})
+
+	require.Equal(t, 1500, quota)
+	require.NotNil(t, result)
+	require.Equal(t, "stream", result.MatchedTier)
+}
+
+func TestBuildTestLogOtherInjectsTieredInfo(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+
+	info := &relaycommon.RelayInfo{
+		TieredBillingSnapshot: &billingexpr.BillingSnapshot{
+			BillingMode: "tiered_expr",
+			ExprString:  `tier("base", p * 2)`,
+		},
+		ChannelMeta: &relaycommon.ChannelMeta{},
+	}
+	priceData := types.PriceData{
+		GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
+	}
+	usage := &dto.Usage{
+		PromptTokensDetails: dto.InputTokenDetails{
+			CachedTokens: 12,
+		},
+	}
+
+	other := buildTestLogOther(ctx, info, priceData, usage, &billingexpr.TieredResult{
+		MatchedTier: "base",
+	})
+
+	require.Equal(t, "tiered_expr", other["billing_mode"])
+	require.Equal(t, "base", other["matched_tier"])
+	require.NotEmpty(t, other["expr_b64"])
+}

+ 31 - 7
controller/channel_upstream_update.go

@@ -3,6 +3,7 @@ package controller
 import (
 	"fmt"
 	"net/http"
+	"regexp"
 	"slices"
 	"strings"
 	"sync"
@@ -31,6 +32,26 @@ const (
 	channelUpstreamModelUpdateNotifyMaxFailedChannelIDs   = 10
 )
 
+var channelUpstreamModelUpdateSelectFields = []string{
+	"id",
+	"name",
+	"type",
+	"key",
+	"status",
+	"base_url",
+	"models",
+	"model_mapping",
+	"settings",
+	"setting",
+	"other",
+	"group",
+	"priority",
+	"weight",
+	"tag",
+	"channel_info",
+	"header_override",
+}
+
 var (
 	channelUpstreamModelUpdateTaskOnce    sync.Once
 	channelUpstreamModelUpdateTaskRunning atomic.Bool
@@ -169,10 +190,7 @@ func collectPendingUpstreamModelChangesFromModels(
 		upstreamSet[modelName] = struct{}{}
 	}
 
-	ignoredSet := make(map[string]struct{})
-	for _, modelName := range normalizeModelNames(ignoredModels) {
-		ignoredSet[modelName] = struct{}{}
-	}
+	normalizedIgnoredModels := normalizeModelNames(ignoredModels)
 
 	redirectSourceSet := make(map[string]struct{}, len(modelMapping))
 	redirectTargetSet := make(map[string]struct{}, len(modelMapping))
@@ -193,7 +211,13 @@ func collectPendingUpstreamModelChangesFromModels(
 		if _, ok := coveredUpstreamSet[modelName]; ok {
 			return false
 		}
-		if _, ok := ignoredSet[modelName]; ok {
+		if lo.ContainsBy(normalizedIgnoredModels, func(ignoredModel string) bool {
+			if regexBody, ok := strings.CutPrefix(ignoredModel, "regex:"); ok {
+				matched, err := regexp.MatchString(strings.TrimSpace(regexBody), modelName)
+				return err == nil && matched
+			}
+			return ignoredModel == modelName
+		}) {
 			return false
 		}
 		return true
@@ -517,7 +541,7 @@ func runChannelUpstreamModelUpdateTaskOnce() {
 	for {
 		var channels []*model.Channel
 		query := model.DB.
-			Select("id", "name", "type", "key", "status", "base_url", "models", "settings", "setting", "other", "group", "priority", "weight", "tag", "channel_info", "header_override").
+			Select(channelUpstreamModelUpdateSelectFields).
 			Where("status = ?", common.ChannelStatusEnabled).
 			Order("id asc").
 			Limit(channelUpstreamModelUpdateTaskBatchSize)
@@ -810,7 +834,7 @@ func collectPendingApplyUpstreamModelChanges(settings dto.ChannelOtherSettings)
 func findEnabledChannelsAfterID(lastID int, batchSize int) ([]*model.Channel, error) {
 	var channels []*model.Channel
 	query := model.DB.
-		Select("id", "name", "type", "key", "status", "base_url", "models", "settings", "setting", "other", "group", "priority", "weight", "tag", "channel_info", "header_override").
+		Select(channelUpstreamModelUpdateSelectFields).
 		Where("status = ?", common.ChannelStatusEnabled).
 		Order("id asc").
 		Limit(batchSize)

+ 16 - 0
controller/channel_upstream_update_test.go

@@ -81,6 +81,10 @@ func TestCollectPendingApplyUpstreamModelChanges(t *testing.T) {
 	require.Equal(t, []string{"old-model"}, pendingRemoveModels)
 }
 
+func TestChannelUpstreamModelUpdateSelectFieldsIncludeModelMapping(t *testing.T) {
+	require.Contains(t, channelUpstreamModelUpdateSelectFields, "model_mapping")
+}
+
 func TestNormalizeChannelModelMapping(t *testing.T) {
 	modelMapping := `{
 		" alias-model ": " upstream-model ",
@@ -111,6 +115,18 @@ func TestCollectPendingUpstreamModelChangesFromModels_WithModelMapping(t *testin
 	require.Equal(t, []string{"stale-model"}, pendingRemoveModels)
 }
 
+func TestCollectPendingUpstreamModelChangesFromModels_WithIgnoredRegexPatterns(t *testing.T) {
+	pendingAddModels, pendingRemoveModels := collectPendingUpstreamModelChangesFromModels(
+		[]string{"gpt-4o"},
+		[]string{"gpt-4o", "claude-3-5-sonnet", "sora-video", "gpt-4.1"},
+		[]string{"regex:^sora-.*$", "gpt-4.1"},
+		nil,
+	)
+
+	require.Equal(t, []string{"claude-3-5-sonnet"}, pendingAddModels)
+	require.Equal(t, []string{}, pendingRemoveModels)
+}
+
 func TestBuildUpstreamModelUpdateTaskNotificationContent_OmitOverflowDetails(t *testing.T) {
 	channelSummaries := make([]upstreamModelUpdateChannelSummary, 0, 12)
 	for i := 0; i < 12; i++ {

+ 223 - 0
controller/discord.go

@@ -0,0 +1,223 @@
+package controller
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/setting/system_setting"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-gonic/gin"
+)
+
+type DiscordResponse struct {
+	AccessToken  string `json:"access_token"`
+	IDToken      string `json:"id_token"`
+	RefreshToken string `json:"refresh_token"`
+	TokenType    string `json:"token_type"`
+	ExpiresIn    int    `json:"expires_in"`
+	Scope        string `json:"scope"`
+}
+
+type DiscordUser struct {
+	UID  string `json:"id"`
+	ID   string `json:"username"`
+	Name string `json:"global_name"`
+}
+
+func getDiscordUserInfoByCode(code string) (*DiscordUser, error) {
+	if code == "" {
+		return nil, errors.New("无效的参数")
+	}
+
+	values := url.Values{}
+	values.Set("client_id", system_setting.GetDiscordSettings().ClientId)
+	values.Set("client_secret", system_setting.GetDiscordSettings().ClientSecret)
+	values.Set("code", code)
+	values.Set("grant_type", "authorization_code")
+	values.Set("redirect_uri", fmt.Sprintf("%s/oauth/discord", system_setting.ServerAddress))
+	formData := values.Encode()
+	req, err := http.NewRequest("POST", "https://discord.com/api/v10/oauth2/token", strings.NewReader(formData))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.Header.Set("Accept", "application/json")
+	client := http.Client{
+		Timeout: 5 * time.Second,
+	}
+	res, err := client.Do(req)
+	if err != nil {
+		common.SysLog(err.Error())
+		return nil, errors.New("无法连接至 Discord 服务器,请稍后重试!")
+	}
+	defer res.Body.Close()
+	var discordResponse DiscordResponse
+	err = json.NewDecoder(res.Body).Decode(&discordResponse)
+	if err != nil {
+		return nil, err
+	}
+
+	if discordResponse.AccessToken == "" {
+		common.SysError("Discord 获取 Token 失败,请检查设置!")
+		return nil, errors.New("Discord 获取 Token 失败,请检查设置!")
+	}
+
+	req, err = http.NewRequest("GET", "https://discord.com/api/v10/users/@me", nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Authorization", "Bearer "+discordResponse.AccessToken)
+	res2, err := client.Do(req)
+	if err != nil {
+		common.SysLog(err.Error())
+		return nil, errors.New("无法连接至 Discord 服务器,请稍后重试!")
+	}
+	defer res2.Body.Close()
+	if res2.StatusCode != http.StatusOK {
+		common.SysError("Discord 获取用户信息失败!请检查设置!")
+		return nil, errors.New("Discord 获取用户信息失败!请检查设置!")
+	}
+
+	var discordUser DiscordUser
+	err = json.NewDecoder(res2.Body).Decode(&discordUser)
+	if err != nil {
+		return nil, err
+	}
+	if discordUser.UID == "" || discordUser.ID == "" {
+		common.SysError("Discord 获取用户信息为空!请检查设置!")
+		return nil, errors.New("Discord 获取用户信息为空!请检查设置!")
+	}
+	return &discordUser, nil
+}
+
+func DiscordOAuth(c *gin.Context) {
+	session := sessions.Default(c)
+	state := c.Query("state")
+	if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
+		c.JSON(http.StatusForbidden, gin.H{
+			"success": false,
+			"message": "state is empty or not same",
+		})
+		return
+	}
+	username := session.Get("username")
+	if username != nil {
+		DiscordBind(c)
+		return
+	}
+	if !system_setting.GetDiscordSettings().Enabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未开启通过 Discord 登录以及注册",
+		})
+		return
+	}
+	code := c.Query("code")
+	discordUser, err := getDiscordUserInfoByCode(code)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	user := model.User{
+		DiscordId: discordUser.UID,
+	}
+	if model.IsDiscordIdAlreadyTaken(user.DiscordId) {
+		err := user.FillUserByDiscordId()
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
+	} else {
+		if common.RegisterEnabled {
+			if discordUser.ID != "" {
+				user.Username = discordUser.ID
+			} else {
+				user.Username = "discord_" + strconv.Itoa(model.GetMaxUserId()+1)
+			}
+			if discordUser.Name != "" {
+				user.DisplayName = discordUser.Name
+			} else {
+				user.DisplayName = "Discord User"
+			}
+			err := user.Insert(0)
+			if err != nil {
+				c.JSON(http.StatusOK, gin.H{
+					"success": false,
+					"message": err.Error(),
+				})
+				return
+			}
+		} else {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "管理员关闭了新用户注册",
+			})
+			return
+		}
+	}
+
+	if user.Status != common.UserStatusEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"message": "用户已被封禁",
+			"success": false,
+		})
+		return
+	}
+	setupLogin(&user, c)
+}
+
+func DiscordBind(c *gin.Context) {
+	if !system_setting.GetDiscordSettings().Enabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未开启通过 Discord 登录以及注册",
+		})
+		return
+	}
+	code := c.Query("code")
+	discordUser, err := getDiscordUserInfoByCode(code)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	user := model.User{
+		DiscordId: discordUser.UID,
+	}
+	if model.IsDiscordIdAlreadyTaken(user.DiscordId) {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "该 Discord 账户已被绑定",
+		})
+		return
+	}
+	session := sessions.Default(c)
+	id := session.Get("id")
+	user.Id = id.(int)
+	err = user.FillUserById()
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	user.DiscordId = discordUser.UID
+	err = user.Update(false)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "bind",
+	})
+}

+ 220 - 0
controller/github.go

@@ -0,0 +1,220 @@
+package controller
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"strconv"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/model"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-gonic/gin"
+)
+
+type GitHubOAuthResponse struct {
+	AccessToken string `json:"access_token"`
+	Scope       string `json:"scope"`
+	TokenType   string `json:"token_type"`
+}
+
+type GitHubUser struct {
+	Login string `json:"login"`
+	Name  string `json:"name"`
+	Email string `json:"email"`
+}
+
+func getGitHubUserInfoByCode(code string) (*GitHubUser, error) {
+	if code == "" {
+		return nil, errors.New("无效的参数")
+	}
+	values := map[string]string{"client_id": common.GitHubClientId, "client_secret": common.GitHubClientSecret, "code": code}
+	jsonData, err := json.Marshal(values)
+	if err != nil {
+		return nil, err
+	}
+	req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(jsonData))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Accept", "application/json")
+	client := http.Client{
+		Timeout: 20 * time.Second,
+	}
+	res, err := client.Do(req)
+	if err != nil {
+		common.SysLog(err.Error())
+		return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
+	}
+	defer res.Body.Close()
+	var oAuthResponse GitHubOAuthResponse
+	err = json.NewDecoder(res.Body).Decode(&oAuthResponse)
+	if err != nil {
+		return nil, err
+	}
+	req, err = http.NewRequest("GET", "https://api.github.com/user", nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oAuthResponse.AccessToken))
+	res2, err := client.Do(req)
+	if err != nil {
+		common.SysLog(err.Error())
+		return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
+	}
+	defer res2.Body.Close()
+	var githubUser GitHubUser
+	err = json.NewDecoder(res2.Body).Decode(&githubUser)
+	if err != nil {
+		return nil, err
+	}
+	if githubUser.Login == "" {
+		return nil, errors.New("返回值非法,用户字段为空,请稍后重试!")
+	}
+	return &githubUser, nil
+}
+
+func GitHubOAuth(c *gin.Context) {
+	session := sessions.Default(c)
+	state := c.Query("state")
+	if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
+		c.JSON(http.StatusForbidden, gin.H{
+			"success": false,
+			"message": "state is empty or not same",
+		})
+		return
+	}
+	username := session.Get("username")
+	if username != nil {
+		GitHubBind(c)
+		return
+	}
+
+	if !common.GitHubOAuthEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未开启通过 GitHub 登录以及注册",
+		})
+		return
+	}
+	code := c.Query("code")
+	githubUser, err := getGitHubUserInfoByCode(code)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	user := model.User{
+		GitHubId: githubUser.Login,
+	}
+	// IsGitHubIdAlreadyTaken is unscoped
+	if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
+		// FillUserByGitHubId is scoped
+		err := user.FillUserByGitHubId()
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
+		// if user.Id == 0 , user has been deleted
+		if user.Id == 0 {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "用户已注销",
+			})
+			return
+		}
+	} else {
+		if common.RegisterEnabled {
+			user.Username = "github_" + strconv.Itoa(model.GetMaxUserId()+1)
+			if githubUser.Name != "" {
+				user.DisplayName = githubUser.Name
+			} else {
+				user.DisplayName = "GitHub User"
+			}
+			user.Email = githubUser.Email
+			user.Role = common.RoleCommonUser
+			user.Status = common.UserStatusEnabled
+			affCode := session.Get("aff")
+			inviterId := 0
+			if affCode != nil {
+				inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
+			}
+
+			if err := user.Insert(inviterId); err != nil {
+				c.JSON(http.StatusOK, gin.H{
+					"success": false,
+					"message": err.Error(),
+				})
+				return
+			}
+		} else {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "管理员关闭了新用户注册",
+			})
+			return
+		}
+	}
+
+	if user.Status != common.UserStatusEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"message": "用户已被封禁",
+			"success": false,
+		})
+		return
+	}
+	setupLogin(&user, c)
+}
+
+func GitHubBind(c *gin.Context) {
+	if !common.GitHubOAuthEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未开启通过 GitHub 登录以及注册",
+		})
+		return
+	}
+	code := c.Query("code")
+	githubUser, err := getGitHubUserInfoByCode(code)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	user := model.User{
+		GitHubId: githubUser.Login,
+	}
+	if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "该 GitHub 账户已被绑定",
+		})
+		return
+	}
+	session := sessions.Default(c)
+	id := session.Get("id")
+	// id := c.GetInt("id")  // critical bug!
+	user.Id = id.(int)
+	err = user.FillUserById()
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	user.GitHubId = githubUser.Login
+	err = user.Update(false)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "bind",
+	})
+	return
+}

+ 268 - 0
controller/linuxdo.go

@@ -0,0 +1,268 @@
+package controller
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/model"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-gonic/gin"
+)
+
+type LinuxdoUser struct {
+	Id         int    `json:"id"`
+	Username   string `json:"username"`
+	Name       string `json:"name"`
+	Active     bool   `json:"active"`
+	TrustLevel int    `json:"trust_level"`
+	Silenced   bool   `json:"silenced"`
+}
+
+func LinuxDoBind(c *gin.Context) {
+	if !common.LinuxDOOAuthEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未开启通过 Linux DO 登录以及注册",
+		})
+		return
+	}
+
+	code := c.Query("code")
+	linuxdoUser, err := getLinuxdoUserInfoByCode(code, c)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	user := model.User{
+		LinuxDOId: strconv.Itoa(linuxdoUser.Id),
+	}
+
+	if model.IsLinuxDOIdAlreadyTaken(user.LinuxDOId) {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "该 Linux DO 账户已被绑定",
+		})
+		return
+	}
+
+	session := sessions.Default(c)
+	id := session.Get("id")
+	user.Id = id.(int)
+
+	err = user.FillUserById()
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	user.LinuxDOId = strconv.Itoa(linuxdoUser.Id)
+	err = user.Update(false)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "bind",
+	})
+}
+
+func getLinuxdoUserInfoByCode(code string, c *gin.Context) (*LinuxdoUser, error) {
+	if code == "" {
+		return nil, errors.New("invalid code")
+	}
+
+	// Get access token using Basic auth
+	tokenEndpoint := common.GetEnvOrDefaultString("LINUX_DO_TOKEN_ENDPOINT", "https://connect.linux.do/oauth2/token")
+	credentials := common.LinuxDOClientId + ":" + common.LinuxDOClientSecret
+	basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
+
+	// Get redirect URI from request
+	scheme := "http"
+	if c.Request.TLS != nil {
+		scheme = "https"
+	}
+	redirectURI := fmt.Sprintf("%s://%s/api/oauth/linuxdo", scheme, c.Request.Host)
+
+	data := url.Values{}
+	data.Set("grant_type", "authorization_code")
+	data.Set("code", code)
+	data.Set("redirect_uri", redirectURI)
+
+	req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(data.Encode()))
+	if err != nil {
+		return nil, err
+	}
+
+	req.Header.Set("Authorization", basicAuth)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.Header.Set("Accept", "application/json")
+
+	client := http.Client{Timeout: 5 * time.Second}
+	res, err := client.Do(req)
+	if err != nil {
+		return nil, errors.New("failed to connect to Linux DO server")
+	}
+	defer res.Body.Close()
+
+	var tokenRes struct {
+		AccessToken string `json:"access_token"`
+		Message     string `json:"message"`
+	}
+	if err := json.NewDecoder(res.Body).Decode(&tokenRes); err != nil {
+		return nil, err
+	}
+
+	if tokenRes.AccessToken == "" {
+		return nil, fmt.Errorf("failed to get access token: %s", tokenRes.Message)
+	}
+
+	// Get user info
+	userEndpoint := common.GetEnvOrDefaultString("LINUX_DO_USER_ENDPOINT", "https://connect.linux.do/api/user")
+	req, err = http.NewRequest("GET", userEndpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Authorization", "Bearer "+tokenRes.AccessToken)
+	req.Header.Set("Accept", "application/json")
+
+	res2, err := client.Do(req)
+	if err != nil {
+		return nil, errors.New("failed to get user info from Linux DO")
+	}
+	defer res2.Body.Close()
+
+	var linuxdoUser LinuxdoUser
+	if err := json.NewDecoder(res2.Body).Decode(&linuxdoUser); err != nil {
+		return nil, err
+	}
+
+	if linuxdoUser.Id == 0 {
+		return nil, errors.New("invalid user info returned")
+	}
+
+	return &linuxdoUser, nil
+}
+
+func LinuxdoOAuth(c *gin.Context) {
+	session := sessions.Default(c)
+
+	errorCode := c.Query("error")
+	if errorCode != "" {
+		errorDescription := c.Query("error_description")
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": errorDescription,
+		})
+		return
+	}
+
+	state := c.Query("state")
+	if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
+		c.JSON(http.StatusForbidden, gin.H{
+			"success": false,
+			"message": "state is empty or not same",
+		})
+		return
+	}
+
+	username := session.Get("username")
+	if username != nil {
+		LinuxDoBind(c)
+		return
+	}
+
+	if !common.LinuxDOOAuthEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未开启通过 Linux DO 登录以及注册",
+		})
+		return
+	}
+
+	code := c.Query("code")
+	linuxdoUser, err := getLinuxdoUserInfoByCode(code, c)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	user := model.User{
+		LinuxDOId: strconv.Itoa(linuxdoUser.Id),
+	}
+
+	// Check if user exists
+	if model.IsLinuxDOIdAlreadyTaken(user.LinuxDOId) {
+		err := user.FillUserByLinuxDOId()
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
+		if user.Id == 0 {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "用户已注销",
+			})
+			return
+		}
+	} else {
+		if common.RegisterEnabled {
+			if linuxdoUser.TrustLevel >= common.LinuxDOMinimumTrustLevel {
+				user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1)
+				user.DisplayName = linuxdoUser.Name
+				user.Role = common.RoleCommonUser
+				user.Status = common.UserStatusEnabled
+
+				affCode := session.Get("aff")
+				inviterId := 0
+				if affCode != nil {
+					inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
+				}
+
+				if err := user.Insert(inviterId); err != nil {
+					c.JSON(http.StatusOK, gin.H{
+						"success": false,
+						"message": err.Error(),
+					})
+					return
+				}
+			} else {
+				c.JSON(http.StatusOK, gin.H{
+					"success": false,
+					"message": "Linux DO 信任等级未达到管理员设置的最低信任等级",
+				})
+				return
+			}
+		} else {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "管理员关闭了新用户注册",
+			})
+			return
+		}
+	}
+
+	if user.Status != common.UserStatusEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"message": "用户已被封禁",
+			"success": false,
+		})
+		return
+	}
+
+	setupLogin(&user, c)
+}

+ 15 - 22
controller/misc.go

@@ -8,6 +8,7 @@ import (
 
 	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/constant"
+	"github.com/QuantumNous/new-api/logger"
 	"github.com/QuantumNous/new-api/middleware"
 	"github.com/QuantumNous/new-api/model"
 	"github.com/QuantumNous/new-api/oauth"
@@ -60,6 +61,7 @@ func GetStatus(c *gin.Context) {
 		"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
 		"telegram_oauth":              common.TelegramOAuthEnabled,
 		"telegram_bot_name":           common.TelegramBotName,
+		"theme":                       system_setting.GetThemeSettings().Frontend,
 		"system_name":                 common.SystemName,
 		"logo":                        common.Logo,
 		"footer_html":                 common.Footer,
@@ -68,7 +70,6 @@ func GetStatus(c *gin.Context) {
 		"server_address":              system_setting.ServerAddress,
 		"turnstile_check":             common.TurnstileCheckEnabled,
 		"turnstile_site_key":          common.TurnstileSiteKey,
-		"top_up_link":                 common.TopUpLink,
 		"docs_link":                   operation_setting.GetGeneralSetting().DocsLink,
 		"quota_per_unit":              common.QuotaPerUnit,
 		// 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type
@@ -116,7 +117,6 @@ func GetStatus(c *gin.Context) {
 		"user_agreement_enabled":      legalSetting.UserAgreement != "",
 		"privacy_policy_enabled":      legalSetting.PrivacyPolicy != "",
 		"checkin_enabled":             operation_setting.GetCheckinSetting().Enabled,
-		"_qn":                         "new-api",
 	}
 
 	// 根据启用状态注入可选内容
@@ -308,31 +308,24 @@ func SendPasswordResetEmail(c *gin.Context) {
 		})
 		return
 	}
-	if !model.IsEmailAlreadyTaken(email) {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "该邮箱地址未注册",
-		})
-		return
-	}
-	code := common.GenerateVerificationCode(0)
-	common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
-	link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
-	subject := fmt.Sprintf("%s密码重置", common.SystemName)
-	content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
-		"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+
-		"<p>如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> %s </p>"+
-		"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
-	err := common.SendEmail(subject, email, content)
-	if err != nil {
-		common.ApiError(c, err)
-		return
+	if model.IsEmailAlreadyTaken(email) {
+		code := common.GenerateVerificationCode(0)
+		common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
+		link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
+		subject := fmt.Sprintf("%s密码重置", common.SystemName)
+		content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
+			"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+
+			"<p>如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> %s </p>"+
+			"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
+		err := common.SendEmail(subject, email, content)
+		if err != nil {
+			logger.LogError(c.Request.Context(), fmt.Sprintf("failed to send password reset email to %s: %s", email, err.Error()))
+		}
 	}
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
 	})
-	return
 }
 
 type PasswordResetRequest struct {

+ 3 - 5
controller/model.go

@@ -15,9 +15,9 @@ import (
 	"github.com/QuantumNous/new-api/relay/channel/minimax"
 	"github.com/QuantumNous/new-api/relay/channel/moonshot"
 	relaycommon "github.com/QuantumNous/new-api/relay/common"
+	"github.com/QuantumNous/new-api/relay/helper"
 	"github.com/QuantumNous/new-api/service"
 	"github.com/QuantumNous/new-api/setting/operation_setting"
-	"github.com/QuantumNous/new-api/setting/ratio_setting"
 	"github.com/QuantumNous/new-api/types"
 	"github.com/gin-gonic/gin"
 	"github.com/samber/lo"
@@ -134,8 +134,7 @@ func ListModels(c *gin.Context, modelType int) {
 		}
 		for allowModel, _ := range tokenModelLimit {
 			if !acceptUnsetRatioModel {
-				_, _, exist := ratio_setting.GetModelRatioOrPrice(allowModel)
-				if !exist {
+				if !helper.HasModelBillingConfig(allowModel) {
 					continue
 				}
 			}
@@ -182,8 +181,7 @@ func ListModels(c *gin.Context, modelType int) {
 		}
 		for _, modelName := range models {
 			if !acceptUnsetRatioModel {
-				_, _, exist := ratio_setting.GetModelRatioOrPrice(modelName)
-				if !exist {
+				if !helper.HasModelBillingConfig(modelName) {
 					continue
 				}
 			}

+ 242 - 0
controller/model_list_test.go

@@ -0,0 +1,242 @@
+package controller
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/constant"
+	"github.com/QuantumNous/new-api/dto"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/setting/config"
+	"github.com/QuantumNous/new-api/setting/operation_setting"
+	"github.com/gin-gonic/gin"
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+)
+
+type listModelsResponse struct {
+	Success bool               `json:"success"`
+	Data    []dto.OpenAIModels `json:"data"`
+	Object  string             `json:"object"`
+}
+
+func setupModelListControllerTestDB(t *testing.T) *gorm.DB {
+	t.Helper()
+
+	initModelListColumnNames(t)
+
+	gin.SetMode(gin.TestMode)
+	common.UsingSQLite = true
+	common.UsingMySQL = false
+	common.UsingPostgreSQL = false
+	common.RedisEnabled = false
+
+	dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
+	db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+	require.NoError(t, err)
+	model.DB = db
+	model.LOG_DB = db
+
+	require.NoError(t, db.AutoMigrate(&model.User{}, &model.Channel{}, &model.Ability{}, &model.Model{}, &model.Vendor{}))
+
+	t.Cleanup(func() {
+		sqlDB, err := db.DB()
+		if err == nil {
+			_ = sqlDB.Close()
+		}
+	})
+
+	return db
+}
+
+func initModelListColumnNames(t *testing.T) {
+	t.Helper()
+
+	originalIsMasterNode := common.IsMasterNode
+	originalSQLitePath := common.SQLitePath
+	originalUsingSQLite := common.UsingSQLite
+	originalUsingMySQL := common.UsingMySQL
+	originalUsingPostgreSQL := common.UsingPostgreSQL
+	originalSQLDSN, hadSQLDSN := os.LookupEnv("SQL_DSN")
+	defer func() {
+		common.IsMasterNode = originalIsMasterNode
+		common.SQLitePath = originalSQLitePath
+		common.UsingSQLite = originalUsingSQLite
+		common.UsingMySQL = originalUsingMySQL
+		common.UsingPostgreSQL = originalUsingPostgreSQL
+		if hadSQLDSN {
+			require.NoError(t, os.Setenv("SQL_DSN", originalSQLDSN))
+		} else {
+			require.NoError(t, os.Unsetenv("SQL_DSN"))
+		}
+	}()
+
+	common.IsMasterNode = false
+	common.SQLitePath = fmt.Sprintf("file:%s_init?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
+	common.UsingSQLite = false
+	common.UsingMySQL = false
+	common.UsingPostgreSQL = false
+	require.NoError(t, os.Setenv("SQL_DSN", "local"))
+
+	require.NoError(t, model.InitDB())
+	if model.DB != nil {
+		sqlDB, err := model.DB.DB()
+		if err == nil {
+			_ = sqlDB.Close()
+		}
+	}
+}
+
+func withTieredBillingConfig(t *testing.T, modes map[string]string, exprs map[string]string) {
+	t.Helper()
+
+	saved := map[string]string{}
+	require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error {
+		if strings.HasPrefix(key, "billing_setting.") {
+			saved[key] = value
+		}
+		return nil
+	}))
+	t.Cleanup(func() {
+		require.NoError(t, config.GlobalConfig.LoadFromDB(saved))
+		model.InvalidatePricingCache()
+	})
+
+	modeBytes, err := common.Marshal(modes)
+	require.NoError(t, err)
+	exprBytes, err := common.Marshal(exprs)
+	require.NoError(t, err)
+
+	require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
+		"billing_setting.billing_mode": string(modeBytes),
+		"billing_setting.billing_expr": string(exprBytes),
+	}))
+	model.InvalidatePricingCache()
+}
+
+func withSelfUseModeDisabled(t *testing.T) {
+	t.Helper()
+
+	original := operation_setting.SelfUseModeEnabled
+	operation_setting.SelfUseModeEnabled = false
+	t.Cleanup(func() {
+		operation_setting.SelfUseModeEnabled = original
+	})
+}
+
+func decodeListModelsResponse(t *testing.T, recorder *httptest.ResponseRecorder) map[string]struct{} {
+	t.Helper()
+
+	require.Equal(t, http.StatusOK, recorder.Code)
+	var payload listModelsResponse
+	require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload))
+	require.True(t, payload.Success)
+	require.Equal(t, "list", payload.Object)
+
+	ids := make(map[string]struct{}, len(payload.Data))
+	for _, item := range payload.Data {
+		ids[item.Id] = struct{}{}
+	}
+	return ids
+}
+
+func pricingByModelName(pricings []model.Pricing) map[string]model.Pricing {
+	byName := make(map[string]model.Pricing, len(pricings))
+	for _, pricing := range pricings {
+		byName[pricing.ModelName] = pricing
+	}
+	return byName
+}
+
+func TestListModelsIncludesTieredBillingModel(t *testing.T) {
+	withSelfUseModeDisabled(t)
+	withTieredBillingConfig(t, map[string]string{
+		"zz-tiered-visible-model":      "tiered_expr",
+		"zz-tiered-empty-expr-model":   "tiered_expr",
+		"zz-tiered-missing-expr-model": "tiered_expr",
+	}, map[string]string{
+		"zz-tiered-visible-model":    `tier("base", p * 1 + c * 2)`,
+		"zz-tiered-empty-expr-model": "   ",
+	})
+
+	db := setupModelListControllerTestDB(t)
+	require.NoError(t, db.Create(&model.User{
+		Id:       1001,
+		Username: "model-list-user",
+		Password: "password",
+		Group:    "default",
+		Status:   common.UserStatusEnabled,
+	}).Error)
+	require.NoError(t, db.Create(&[]model.Ability{
+		{Group: "default", Model: "zz-tiered-visible-model", ChannelId: 1, Enabled: true},
+		{Group: "default", Model: "zz-tiered-empty-expr-model", ChannelId: 1, Enabled: true},
+		{Group: "default", Model: "zz-tiered-missing-expr-model", ChannelId: 1, Enabled: true},
+		{Group: "default", Model: "zz-unpriced-model", ChannelId: 1, Enabled: true},
+	}).Error)
+
+	recorder := httptest.NewRecorder()
+	ctx, _ := gin.CreateTestContext(recorder)
+	ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
+	ctx.Set("id", 1001)
+
+	ListModels(ctx, constant.ChannelTypeOpenAI)
+
+	ids := decodeListModelsResponse(t, recorder)
+	require.Contains(t, ids, "zz-tiered-visible-model")
+	require.NotContains(t, ids, "zz-tiered-empty-expr-model")
+	require.NotContains(t, ids, "zz-tiered-missing-expr-model")
+	require.NotContains(t, ids, "zz-unpriced-model")
+
+	pricingByName := pricingByModelName(model.GetPricing())
+	visiblePricing, ok := pricingByName["zz-tiered-visible-model"]
+	require.True(t, ok)
+	require.Equal(t, "tiered_expr", visiblePricing.BillingMode)
+	require.NotEmpty(t, visiblePricing.BillingExpr)
+
+	emptyExprPricing, ok := pricingByName["zz-tiered-empty-expr-model"]
+	require.True(t, ok)
+	require.Empty(t, emptyExprPricing.BillingMode)
+	require.Empty(t, emptyExprPricing.BillingExpr)
+
+	missingExprPricing, ok := pricingByName["zz-tiered-missing-expr-model"]
+	require.True(t, ok)
+	require.Empty(t, missingExprPricing.BillingMode)
+	require.Empty(t, missingExprPricing.BillingExpr)
+}
+
+func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) {
+	withSelfUseModeDisabled(t)
+	withTieredBillingConfig(t, map[string]string{
+		"zz-token-tiered-visible-model":      "tiered_expr",
+		"zz-token-tiered-empty-expr-model":   "tiered_expr",
+		"zz-token-tiered-missing-expr-model": "tiered_expr",
+	}, map[string]string{
+		"zz-token-tiered-visible-model":    `tier("base", p * 1 + c * 2)`,
+		"zz-token-tiered-empty-expr-model": "",
+	})
+
+	recorder := httptest.NewRecorder()
+	ctx, _ := gin.CreateTestContext(recorder)
+	ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
+	common.SetContextKey(ctx, constant.ContextKeyTokenModelLimitEnabled, true)
+	common.SetContextKey(ctx, constant.ContextKeyTokenModelLimit, map[string]bool{
+		"zz-token-tiered-visible-model":      true,
+		"zz-token-tiered-empty-expr-model":   true,
+		"zz-token-tiered-missing-expr-model": true,
+		"zz-token-unpriced-model":            true,
+	})
+
+	ListModels(ctx, constant.ChannelTypeOpenAI)
+
+	ids := decodeListModelsResponse(t, recorder)
+	require.Contains(t, ids, "zz-token-tiered-visible-model")
+	require.NotContains(t, ids, "zz-token-tiered-empty-expr-model")
+	require.NotContains(t, ids, "zz-token-tiered-missing-expr-model")
+	require.NotContains(t, ids, "zz-token-unpriced-model")
+}

+ 3 - 1
controller/oauth.go

@@ -190,7 +190,9 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider) {
 		}
 	}
 
-	common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, nil)
+	common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{
+		"action": "bind",
+	})
 }
 
 // findOrCreateOAuthUser finds existing user or creates new user

+ 228 - 0
controller/oidc.go

@@ -0,0 +1,228 @@
+package controller
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/setting/system_setting"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-gonic/gin"
+)
+
+type OidcResponse struct {
+	AccessToken  string `json:"access_token"`
+	IDToken      string `json:"id_token"`
+	RefreshToken string `json:"refresh_token"`
+	TokenType    string `json:"token_type"`
+	ExpiresIn    int    `json:"expires_in"`
+	Scope        string `json:"scope"`
+}
+
+type OidcUser struct {
+	OpenID            string `json:"sub"`
+	Email             string `json:"email"`
+	Name              string `json:"name"`
+	PreferredUsername string `json:"preferred_username"`
+	Picture           string `json:"picture"`
+}
+
+func getOidcUserInfoByCode(code string) (*OidcUser, error) {
+	if code == "" {
+		return nil, errors.New("无效的参数")
+	}
+
+	values := url.Values{}
+	values.Set("client_id", system_setting.GetOIDCSettings().ClientId)
+	values.Set("client_secret", system_setting.GetOIDCSettings().ClientSecret)
+	values.Set("code", code)
+	values.Set("grant_type", "authorization_code")
+	values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress))
+	formData := values.Encode()
+	req, err := http.NewRequest("POST", system_setting.GetOIDCSettings().TokenEndpoint, strings.NewReader(formData))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.Header.Set("Accept", "application/json")
+	client := http.Client{
+		Timeout: 5 * time.Second,
+	}
+	res, err := client.Do(req)
+	if err != nil {
+		common.SysLog(err.Error())
+		return nil, errors.New("无法连接至 OIDC 服务器,请稍后重试!")
+	}
+	defer res.Body.Close()
+	var oidcResponse OidcResponse
+	err = json.NewDecoder(res.Body).Decode(&oidcResponse)
+	if err != nil {
+		return nil, err
+	}
+
+	if oidcResponse.AccessToken == "" {
+		common.SysLog("OIDC 获取 Token 失败,请检查设置!")
+		return nil, errors.New("OIDC 获取 Token 失败,请检查设置!")
+	}
+
+	req, err = http.NewRequest("GET", system_setting.GetOIDCSettings().UserInfoEndpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Authorization", "Bearer "+oidcResponse.AccessToken)
+	res2, err := client.Do(req)
+	if err != nil {
+		common.SysLog(err.Error())
+		return nil, errors.New("无法连接至 OIDC 服务器,请稍后重试!")
+	}
+	defer res2.Body.Close()
+	if res2.StatusCode != http.StatusOK {
+		common.SysLog("OIDC 获取用户信息失败!请检查设置!")
+		return nil, errors.New("OIDC 获取用户信息失败!请检查设置!")
+	}
+
+	var oidcUser OidcUser
+	err = json.NewDecoder(res2.Body).Decode(&oidcUser)
+	if err != nil {
+		return nil, err
+	}
+	if oidcUser.OpenID == "" || oidcUser.Email == "" {
+		common.SysLog("OIDC 获取用户信息为空!请检查设置!")
+		return nil, errors.New("OIDC 获取用户信息为空!请检查设置!")
+	}
+	return &oidcUser, nil
+}
+
+func OidcAuth(c *gin.Context) {
+	session := sessions.Default(c)
+	state := c.Query("state")
+	if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
+		c.JSON(http.StatusForbidden, gin.H{
+			"success": false,
+			"message": "state is empty or not same",
+		})
+		return
+	}
+	username := session.Get("username")
+	if username != nil {
+		OidcBind(c)
+		return
+	}
+	if !system_setting.GetOIDCSettings().Enabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未开启通过 OIDC 登录以及注册",
+		})
+		return
+	}
+	code := c.Query("code")
+	oidcUser, err := getOidcUserInfoByCode(code)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	user := model.User{
+		OidcId: oidcUser.OpenID,
+	}
+	if model.IsOidcIdAlreadyTaken(user.OidcId) {
+		err := user.FillUserByOidcId()
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
+	} else {
+		if common.RegisterEnabled {
+			user.Email = oidcUser.Email
+			if oidcUser.PreferredUsername != "" {
+				user.Username = oidcUser.PreferredUsername
+			} else {
+				user.Username = "oidc_" + strconv.Itoa(model.GetMaxUserId()+1)
+			}
+			if oidcUser.Name != "" {
+				user.DisplayName = oidcUser.Name
+			} else {
+				user.DisplayName = "OIDC User"
+			}
+			err := user.Insert(0)
+			if err != nil {
+				c.JSON(http.StatusOK, gin.H{
+					"success": false,
+					"message": err.Error(),
+				})
+				return
+			}
+		} else {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "管理员关闭了新用户注册",
+			})
+			return
+		}
+	}
+
+	if user.Status != common.UserStatusEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"message": "用户已被封禁",
+			"success": false,
+		})
+		return
+	}
+	setupLogin(&user, c)
+}
+
+func OidcBind(c *gin.Context) {
+	if !system_setting.GetOIDCSettings().Enabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未开启通过 OIDC 登录以及注册",
+		})
+		return
+	}
+	code := c.Query("code")
+	oidcUser, err := getOidcUserInfoByCode(code)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	user := model.User{
+		OidcId: oidcUser.OpenID,
+	}
+	if model.IsOidcIdAlreadyTaken(user.OidcId) {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "该 OIDC 账户已被绑定",
+		})
+		return
+	}
+	session := sessions.Default(c)
+	id := session.Get("id")
+	// id := c.GetInt("id")  // critical bug!
+	user.Id = id.(int)
+	err = user.FillUserById()
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	user.OidcId = oidcUser.OpenID
+	err = user.Update(false)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "bind",
+	})
+	return
+}

+ 78 - 5
controller/option.go

@@ -1,7 +1,6 @@
 package controller
 
 import (
-	"encoding/json"
 	"fmt"
 	"net/http"
 	"strings"
@@ -17,23 +16,89 @@ import (
 	"github.com/gin-gonic/gin"
 )
 
+var completionRatioMetaOptionKeys = []string{
+	"ModelPrice",
+	"ModelRatio",
+	"CompletionRatio",
+	"CacheRatio",
+	"CreateCacheRatio",
+	"ImageRatio",
+	"AudioRatio",
+	"AudioCompletionRatio",
+}
+
+func isVisiblePublicKeyOption(key string) bool {
+	switch key {
+	case "WaffoPancakeWebhookPublicKey", "WaffoPancakeWebhookTestKey":
+		return true
+	default:
+		return false
+	}
+}
+
+func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
+	if strings.TrimSpace(raw) == "" {
+		return
+	}
+
+	var parsed map[string]any
+	if err := common.UnmarshalJsonStr(raw, &parsed); err != nil {
+		return
+	}
+
+	for modelName := range parsed {
+		modelNames[modelName] = struct{}{}
+	}
+}
+
+func buildCompletionRatioMetaValue(optionValues map[string]string) string {
+	modelNames := make(map[string]struct{})
+	for _, key := range completionRatioMetaOptionKeys {
+		collectModelNamesFromOptionValue(optionValues[key], modelNames)
+	}
+
+	meta := make(map[string]ratio_setting.CompletionRatioInfo, len(modelNames))
+	for modelName := range modelNames {
+		meta[modelName] = ratio_setting.GetCompletionRatioInfo(modelName)
+	}
+
+	jsonBytes, err := common.Marshal(meta)
+	if err != nil {
+		return "{}"
+	}
+	return string(jsonBytes)
+}
+
 func GetOptions(c *gin.Context) {
 	var options []*model.Option
+	optionValues := make(map[string]string)
 	common.OptionMapRWMutex.Lock()
 	for k, v := range common.OptionMap {
-		if strings.HasSuffix(k, "Token") ||
+		value := common.Interface2String(v)
+		isSensitiveKey := strings.HasSuffix(k, "Token") ||
 			strings.HasSuffix(k, "Secret") ||
 			strings.HasSuffix(k, "Key") ||
 			strings.HasSuffix(k, "secret") ||
-			strings.HasSuffix(k, "api_key") {
+			strings.HasSuffix(k, "api_key")
+		if isSensitiveKey && !isVisiblePublicKeyOption(k) {
 			continue
 		}
 		options = append(options, &model.Option{
 			Key:   k,
-			Value: common.Interface2String(v),
+			Value: value,
 		})
+		for _, optionKey := range completionRatioMetaOptionKeys {
+			if optionKey == k {
+				optionValues[k] = value
+				break
+			}
+		}
 	}
 	common.OptionMapRWMutex.Unlock()
+	options = append(options, &model.Option{
+		Key:   "CompletionRatioMeta",
+		Value: buildCompletionRatioMetaValue(optionValues),
+	})
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
@@ -49,7 +114,7 @@ type OptionUpdateRequest struct {
 
 func UpdateOption(c *gin.Context) {
 	var option OptionUpdateRequest
-	err := json.NewDecoder(c.Request.Body).Decode(&option)
+	err := common.DecodeJson(c.Request.Body, &option)
 	if err != nil {
 		c.JSON(http.StatusBadRequest, gin.H{
 			"success": false,
@@ -133,6 +198,14 @@ func UpdateOption(c *gin.Context) {
 			})
 			return
 		}
+	case "theme.frontend":
+		if option.Value != "default" && option.Value != "classic" {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "无效的主题值,可选值:default(新版前端)、classic(经典前端)",
+			})
+			return
+		}
 	case "GroupRatio":
 		err = ratio_setting.CheckGroupRatio(option.Value.(string))
 		if err != nil {

+ 79 - 0
controller/passkey.go

@@ -36,6 +36,10 @@ func PasskeyRegisterBegin(c *gin.Context) {
 		return
 	}
 
+	if !requirePasskeyRegistrationVerification(c, user.Id) {
+		return
+	}
+
 	credential, err := model.GetPasskeyByUserID(user.Id)
 	if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
 		common.ApiError(c, err)
@@ -96,6 +100,10 @@ func PasskeyRegisterFinish(c *gin.Context) {
 		return
 	}
 
+	if !requirePasskeyRegistrationVerification(c, user.Id) {
+		return
+	}
+
 	wa, err := passkeysvc.BuildWebAuthn(c.Request)
 	if err != nil {
 		common.ApiError(c, err)
@@ -151,6 +159,10 @@ func PasskeyDelete(c *gin.Context) {
 		return
 	}
 
+	if !requirePasskeyDeleteVerification(c, user.Id) {
+		return
+	}
+
 	if err := model.DeletePasskeyByUserID(user.Id); err != nil {
 		common.ApiError(c, err)
 		return
@@ -470,6 +482,16 @@ func PasskeyVerifyFinish(c *gin.Context) {
 		return
 	}
 
+	session := sessions.Default(c)
+	// Mark passkey as ready; /api/verify will convert this into the final secure verification session.
+	session.Set(PasskeyReadySessionKey, time.Now().Unix())
+	session.Delete(SecureVerificationSessionKey)
+	session.Delete(secureVerificationMethodSessionKey)
+	if err := session.Save(); err != nil {
+		common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
+		return
+	}
+
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "Passkey 验证成功",
@@ -495,3 +517,60 @@ func getSessionUser(c *gin.Context) (*model.User, error) {
 	}
 	return user, nil
 }
+
+func requirePasskeyRegistrationVerification(c *gin.Context, userID int) bool {
+	twoFA, err := model.GetTwoFAByUserId(userID)
+	if err != nil {
+		common.ApiError(c, err)
+		return false
+	}
+	if twoFA == nil || !twoFA.IsEnabled {
+		return true
+	}
+	return requireSecureVerificationMethod(c, secureVerificationMethod2FA)
+}
+
+func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool {
+	twoFA, err := model.GetTwoFAByUserId(userID)
+	if err != nil {
+		common.ApiError(c, err)
+		return false
+	}
+	if twoFA != nil && twoFA.IsEnabled {
+		return requireSecureVerificationMethod(c, secureVerificationMethod2FA)
+	}
+
+	_, err = model.GetPasskeyByUserID(userID)
+	if err != nil {
+		if errors.Is(err, model.ErrPasskeyNotFound) {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "该用户尚未绑定 Passkey",
+			})
+			return false
+		}
+		common.ApiError(c, err)
+		return false
+	}
+
+	return requireSecureVerificationMethod(c, secureVerificationMethodPasskey)
+}
+
+func requireSecureVerificationMethod(c *gin.Context, method string) bool {
+	session := sessions.Default(c)
+	verifiedAt, ok := session.Get(SecureVerificationSessionKey).(int64)
+	if !ok || time.Now().Unix()-verifiedAt >= SecureVerificationTimeout {
+		session.Delete(SecureVerificationSessionKey)
+		session.Delete(secureVerificationMethodSessionKey)
+		_ = session.Save()
+		common.ApiErrorMsg(c, "请先完成安全验证")
+		return false
+	}
+
+	if verifiedMethod, ok := session.Get(secureVerificationMethodSessionKey).(string); !ok || verifiedMethod != method {
+		common.ApiErrorMsg(c, "请先完成对应的安全验证")
+		return false
+	}
+
+	return true
+}

+ 100 - 0
controller/payment_webhook_availability.go

@@ -0,0 +1,100 @@
+package controller
+
+import (
+	"strings"
+
+	"github.com/QuantumNous/new-api/setting"
+	"github.com/QuantumNous/new-api/setting/operation_setting"
+)
+
+func isStripeTopUpEnabled() bool {
+	return strings.TrimSpace(setting.StripeApiSecret) != "" &&
+		strings.TrimSpace(setting.StripeWebhookSecret) != "" &&
+		strings.TrimSpace(setting.StripePriceId) != ""
+}
+
+func isStripeWebhookConfigured() bool {
+	return strings.TrimSpace(setting.StripeWebhookSecret) != ""
+}
+
+func isStripeWebhookEnabled() bool {
+	return isStripeTopUpEnabled()
+}
+
+func isCreemTopUpEnabled() bool {
+	products := strings.TrimSpace(setting.CreemProducts)
+	return strings.TrimSpace(setting.CreemApiKey) != "" &&
+		products != "" &&
+		products != "[]"
+}
+
+func isCreemWebhookConfigured() bool {
+	return strings.TrimSpace(setting.CreemWebhookSecret) != ""
+}
+
+func isCreemWebhookEnabled() bool {
+	return isCreemTopUpEnabled() && isCreemWebhookConfigured()
+}
+
+func isWaffoTopUpEnabled() bool {
+	if !setting.WaffoEnabled {
+		return false
+	}
+
+	return isWaffoWebhookConfigured()
+}
+
+func isWaffoWebhookConfigured() bool {
+	if setting.WaffoSandbox {
+		return strings.TrimSpace(setting.WaffoSandboxApiKey) != "" &&
+			strings.TrimSpace(setting.WaffoSandboxPrivateKey) != "" &&
+			strings.TrimSpace(setting.WaffoSandboxPublicCert) != ""
+	}
+
+	return strings.TrimSpace(setting.WaffoApiKey) != "" &&
+		strings.TrimSpace(setting.WaffoPrivateKey) != "" &&
+		strings.TrimSpace(setting.WaffoPublicCert) != ""
+}
+
+func isWaffoWebhookEnabled() bool {
+	return isWaffoTopUpEnabled()
+}
+
+func isWaffoPancakeTopUpEnabled() bool {
+	if !setting.WaffoPancakeEnabled {
+		return false
+	}
+
+	return isWaffoPancakeWebhookConfigured() &&
+		strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
+		strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" &&
+		strings.TrimSpace(setting.WaffoPancakeStoreID) != "" &&
+		strings.TrimSpace(setting.WaffoPancakeProductID) != ""
+}
+
+func isWaffoPancakeWebhookConfigured() bool {
+	currentWebhookKey := strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey)
+	if setting.WaffoPancakeSandbox {
+		currentWebhookKey = strings.TrimSpace(setting.WaffoPancakeWebhookTestKey)
+	}
+
+	return currentWebhookKey != ""
+}
+
+func isWaffoPancakeWebhookEnabled() bool {
+	return isWaffoPancakeTopUpEnabled()
+}
+
+func isEpayTopUpEnabled() bool {
+	return isEpayWebhookConfigured() && len(operation_setting.PayMethods) > 0
+}
+
+func isEpayWebhookConfigured() bool {
+	return strings.TrimSpace(operation_setting.PayAddress) != "" &&
+		strings.TrimSpace(operation_setting.EpayId) != "" &&
+		strings.TrimSpace(operation_setting.EpayKey) != ""
+}
+
+func isEpayWebhookEnabled() bool {
+	return isEpayTopUpEnabled()
+}

+ 166 - 0
controller/payment_webhook_availability_test.go

@@ -0,0 +1,166 @@
+package controller
+
+import (
+	"testing"
+
+	"github.com/QuantumNous/new-api/setting"
+	"github.com/QuantumNous/new-api/setting/operation_setting"
+	"github.com/stretchr/testify/require"
+)
+
+func TestStripeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
+	originalAPISecret := setting.StripeApiSecret
+	originalWebhookSecret := setting.StripeWebhookSecret
+	originalPriceID := setting.StripePriceId
+	t.Cleanup(func() {
+		setting.StripeApiSecret = originalAPISecret
+		setting.StripeWebhookSecret = originalWebhookSecret
+		setting.StripePriceId = originalPriceID
+	})
+
+	setting.StripeWebhookSecret = ""
+	setting.StripeApiSecret = "sk_test_123"
+	setting.StripePriceId = "price_123"
+	require.False(t, isStripeWebhookEnabled())
+
+	setting.StripeWebhookSecret = "whsec_test"
+	require.True(t, isStripeWebhookEnabled())
+
+	setting.StripePriceId = ""
+	require.False(t, isStripeWebhookEnabled())
+}
+
+func TestCreemWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
+	originalAPIKey := setting.CreemApiKey
+	originalProducts := setting.CreemProducts
+	originalWebhookSecret := setting.CreemWebhookSecret
+	t.Cleanup(func() {
+		setting.CreemApiKey = originalAPIKey
+		setting.CreemProducts = originalProducts
+		setting.CreemWebhookSecret = originalWebhookSecret
+	})
+
+	setting.CreemWebhookSecret = ""
+	setting.CreemApiKey = "creem_api_key"
+	setting.CreemProducts = `[{"productId":"prod_123"}]`
+	require.False(t, isCreemWebhookEnabled())
+
+	setting.CreemWebhookSecret = "creem_secret"
+	require.True(t, isCreemWebhookEnabled())
+
+	setting.CreemProducts = "[]"
+	require.False(t, isCreemWebhookEnabled())
+}
+
+func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
+	originalEnabled := setting.WaffoEnabled
+	originalSandbox := setting.WaffoSandbox
+	originalAPIKey := setting.WaffoApiKey
+	originalPrivateKey := setting.WaffoPrivateKey
+	originalPublicCert := setting.WaffoPublicCert
+	originalSandboxAPIKey := setting.WaffoSandboxApiKey
+	originalSandboxPrivateKey := setting.WaffoSandboxPrivateKey
+	originalSandboxPublicCert := setting.WaffoSandboxPublicCert
+	t.Cleanup(func() {
+		setting.WaffoEnabled = originalEnabled
+		setting.WaffoSandbox = originalSandbox
+		setting.WaffoApiKey = originalAPIKey
+		setting.WaffoPrivateKey = originalPrivateKey
+		setting.WaffoPublicCert = originalPublicCert
+		setting.WaffoSandboxApiKey = originalSandboxAPIKey
+		setting.WaffoSandboxPrivateKey = originalSandboxPrivateKey
+		setting.WaffoSandboxPublicCert = originalSandboxPublicCert
+	})
+
+	setting.WaffoEnabled = true
+	setting.WaffoSandbox = false
+	setting.WaffoApiKey = ""
+	setting.WaffoPrivateKey = "private"
+	setting.WaffoPublicCert = "public"
+	require.False(t, isWaffoWebhookEnabled())
+
+	setting.WaffoApiKey = "api"
+	require.True(t, isWaffoWebhookEnabled())
+
+	setting.WaffoEnabled = false
+	require.False(t, isWaffoWebhookEnabled())
+
+	setting.WaffoEnabled = true
+	setting.WaffoSandbox = true
+	setting.WaffoSandboxApiKey = ""
+	setting.WaffoSandboxPrivateKey = "sandbox_private"
+	setting.WaffoSandboxPublicCert = "sandbox_public"
+	require.False(t, isWaffoWebhookEnabled())
+
+	setting.WaffoSandboxApiKey = "sandbox_api"
+	require.True(t, isWaffoWebhookEnabled())
+}
+
+func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
+	originalEnabled := setting.WaffoPancakeEnabled
+	originalSandbox := setting.WaffoPancakeSandbox
+	originalMerchantID := setting.WaffoPancakeMerchantID
+	originalPrivateKey := setting.WaffoPancakePrivateKey
+	originalWebhookPublicKey := setting.WaffoPancakeWebhookPublicKey
+	originalWebhookTestKey := setting.WaffoPancakeWebhookTestKey
+	originalStoreID := setting.WaffoPancakeStoreID
+	originalProductID := setting.WaffoPancakeProductID
+	t.Cleanup(func() {
+		setting.WaffoPancakeEnabled = originalEnabled
+		setting.WaffoPancakeSandbox = originalSandbox
+		setting.WaffoPancakeMerchantID = originalMerchantID
+		setting.WaffoPancakePrivateKey = originalPrivateKey
+		setting.WaffoPancakeWebhookPublicKey = originalWebhookPublicKey
+		setting.WaffoPancakeWebhookTestKey = originalWebhookTestKey
+		setting.WaffoPancakeStoreID = originalStoreID
+		setting.WaffoPancakeProductID = originalProductID
+	})
+
+	setting.WaffoPancakeEnabled = true
+	setting.WaffoPancakeSandbox = false
+	setting.WaffoPancakeMerchantID = "merchant"
+	setting.WaffoPancakePrivateKey = "private"
+	setting.WaffoPancakeStoreID = "store"
+	setting.WaffoPancakeProductID = "product"
+	setting.WaffoPancakeWebhookPublicKey = ""
+	require.False(t, isWaffoPancakeWebhookEnabled())
+
+	setting.WaffoPancakeWebhookPublicKey = "public"
+	require.True(t, isWaffoPancakeWebhookEnabled())
+
+	setting.WaffoPancakeEnabled = false
+	require.False(t, isWaffoPancakeWebhookEnabled())
+
+	setting.WaffoPancakeEnabled = true
+	setting.WaffoPancakeSandbox = true
+	setting.WaffoPancakeWebhookTestKey = ""
+	require.False(t, isWaffoPancakeWebhookEnabled())
+
+	setting.WaffoPancakeWebhookTestKey = "test_public"
+	require.True(t, isWaffoPancakeWebhookEnabled())
+}
+
+func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
+	originalPayAddress := operation_setting.PayAddress
+	originalEpayID := operation_setting.EpayId
+	originalEpayKey := operation_setting.EpayKey
+	originalPayMethods := operation_setting.PayMethods
+	t.Cleanup(func() {
+		operation_setting.PayAddress = originalPayAddress
+		operation_setting.EpayId = originalEpayID
+		operation_setting.EpayKey = originalEpayKey
+		operation_setting.PayMethods = originalPayMethods
+	})
+
+	operation_setting.PayAddress = "https://pay.example.com"
+	operation_setting.EpayId = "epay_id"
+	operation_setting.EpayKey = ""
+	operation_setting.PayMethods = []map[string]string{{"type": "alipay"}}
+	require.False(t, isEpayWebhookEnabled())
+
+	operation_setting.EpayKey = "epay_key"
+	require.True(t, isEpayWebhookEnabled())
+
+	operation_setting.PayMethods = nil
+	require.False(t, isEpayWebhookEnabled())
+}

+ 69 - 0
controller/perf_metrics.go

@@ -0,0 +1,69 @@
+package controller
+
+import (
+	"net/http"
+	"strconv"
+
+	perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
+
+	"github.com/gin-gonic/gin"
+)
+
+func GetPerfMetricsSummary(c *gin.Context) {
+	hours := 24
+	if rawHours := c.Query("hours"); rawHours != "" {
+		if parsed, err := strconv.Atoi(rawHours); err == nil {
+			hours = parsed
+		}
+	}
+
+	result, err := perfmetrics.QuerySummaryAll(hours)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"data":    result,
+	})
+}
+
+func GetPerfMetrics(c *gin.Context) {
+	modelName := c.Query("model")
+	if modelName == "" {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": "model is required",
+		})
+		return
+	}
+
+	hours := 24
+	if rawHours := c.Query("hours"); rawHours != "" {
+		if parsed, err := strconv.Atoi(rawHours); err == nil {
+			hours = parsed
+		}
+	}
+
+	result, err := perfmetrics.Query(perfmetrics.QueryParams{
+		Model: modelName,
+		Group: c.Query("group"),
+		Hours: hours,
+	})
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"data":    result,
+	})
+}

+ 183 - 0
controller/performance.go

@@ -1,12 +1,18 @@
 package controller
 
 import (
+	"fmt"
 	"net/http"
 	"os"
+	"path/filepath"
 	"runtime"
+	"sort"
+	"strconv"
+	"strings"
 	"time"
 
 	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/logger"
 	"github.com/gin-gonic/gin"
 )
 
@@ -169,6 +175,183 @@ func ForceGC(c *gin.Context) {
 	})
 }
 
+// LogFileInfo 日志文件信息
+type LogFileInfo struct {
+	Name    string    `json:"name"`
+	Size    int64     `json:"size"`
+	ModTime time.Time `json:"mod_time"`
+}
+
+// LogFilesResponse 日志文件列表响应
+type LogFilesResponse struct {
+	LogDir     string        `json:"log_dir"`
+	Enabled    bool          `json:"enabled"`
+	FileCount  int           `json:"file_count"`
+	TotalSize  int64         `json:"total_size"`
+	OldestTime *time.Time    `json:"oldest_time,omitempty"`
+	NewestTime *time.Time    `json:"newest_time,omitempty"`
+	Files      []LogFileInfo `json:"files"`
+}
+
+// getLogFiles 读取日志目录中的日志文件列表
+func getLogFiles() ([]LogFileInfo, error) {
+	if *common.LogDir == "" {
+		return nil, nil
+	}
+	entries, err := os.ReadDir(*common.LogDir)
+	if err != nil {
+		return nil, err
+	}
+	var files []LogFileInfo
+	for _, entry := range entries {
+		if entry.IsDir() {
+			continue
+		}
+		name := entry.Name()
+		if !strings.HasPrefix(name, "oneapi-") || !strings.HasSuffix(name, ".log") {
+			continue
+		}
+		info, err := entry.Info()
+		if err != nil {
+			continue
+		}
+		files = append(files, LogFileInfo{
+			Name:    name,
+			Size:    info.Size(),
+			ModTime: info.ModTime(),
+		})
+	}
+	// 按文件名降序排列(最新在前)
+	sort.Slice(files, func(i, j int) bool {
+		return files[i].Name > files[j].Name
+	})
+	return files, nil
+}
+
+// GetLogFiles 获取日志文件列表
+func GetLogFiles(c *gin.Context) {
+	if *common.LogDir == "" {
+		common.ApiSuccess(c, LogFilesResponse{Enabled: false})
+		return
+	}
+	files, err := getLogFiles()
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	var totalSize int64
+	var oldest, newest time.Time
+	for i, f := range files {
+		totalSize += f.Size
+		if i == 0 || f.ModTime.Before(oldest) {
+			oldest = f.ModTime
+		}
+		if i == 0 || f.ModTime.After(newest) {
+			newest = f.ModTime
+		}
+	}
+	resp := LogFilesResponse{
+		LogDir:    *common.LogDir,
+		Enabled:   true,
+		FileCount: len(files),
+		TotalSize: totalSize,
+		Files:     files,
+	}
+	if len(files) > 0 {
+		resp.OldestTime = &oldest
+		resp.NewestTime = &newest
+	}
+	common.ApiSuccess(c, resp)
+}
+
+// CleanupLogFiles 清理过期日志文件
+func CleanupLogFiles(c *gin.Context) {
+	mode := c.Query("mode")
+	valueStr := c.Query("value")
+	if mode != "by_count" && mode != "by_days" {
+		common.ApiErrorMsg(c, "invalid mode, must be by_count or by_days")
+		return
+	}
+	value, err := strconv.Atoi(valueStr)
+	if err != nil || value < 1 {
+		common.ApiErrorMsg(c, "invalid value, must be a positive integer")
+		return
+	}
+	if *common.LogDir == "" {
+		common.ApiErrorMsg(c, "log directory not configured")
+		return
+	}
+
+	files, err := getLogFiles()
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	activeLogPath := logger.GetCurrentLogPath()
+	var toDelete []LogFileInfo
+
+	switch mode {
+	case "by_count":
+		// files 已按名称降序(最新在前),保留前 value 个
+		for i, f := range files {
+			if i < value {
+				continue
+			}
+			fullPath := filepath.Join(*common.LogDir, f.Name)
+			if fullPath == activeLogPath {
+				continue
+			}
+			toDelete = append(toDelete, f)
+		}
+	case "by_days":
+		cutoff := time.Now().AddDate(0, 0, -value)
+		for _, f := range files {
+			if f.ModTime.Before(cutoff) {
+				fullPath := filepath.Join(*common.LogDir, f.Name)
+				if fullPath == activeLogPath {
+					continue
+				}
+				toDelete = append(toDelete, f)
+			}
+		}
+	}
+
+	var deletedCount int
+	var freedBytes int64
+	var failedFiles []string
+	for _, f := range toDelete {
+		fullPath := filepath.Join(*common.LogDir, f.Name)
+		if err := os.Remove(fullPath); err != nil {
+			failedFiles = append(failedFiles, f.Name)
+			continue
+		}
+		deletedCount++
+		freedBytes += f.Size
+	}
+
+	result := gin.H{
+		"deleted_count": deletedCount,
+		"freed_bytes":   freedBytes,
+		"failed_files":  failedFiles,
+	}
+
+	if len(failedFiles) > 0 {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": fmt.Sprintf("部分文件删除失败(%d/%d)", len(failedFiles), len(toDelete)),
+			"data":    result,
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    result,
+	})
+}
+
 // getDiskCacheInfo 获取磁盘缓存目录信息
 func getDiskCacheInfo() DiskCacheInfo {
 	// 使用统一的缓存目录

+ 27 - 1
controller/pricing.go

@@ -1,6 +1,7 @@
 package controller
 
 import (
+	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/model"
 	"github.com/QuantumNous/new-api/service"
 	"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -8,6 +9,30 @@ import (
 	"github.com/gin-gonic/gin"
 )
 
+func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing {
+	if len(pricing) == 0 {
+		return pricing
+	}
+	if len(usableGroup) == 0 {
+		return []model.Pricing{}
+	}
+
+	filtered := make([]model.Pricing, 0, len(pricing))
+	for _, item := range pricing {
+		if common.StringsContains(item.EnableGroup, "all") {
+			filtered = append(filtered, item)
+			continue
+		}
+		for _, group := range item.EnableGroup {
+			if _, ok := usableGroup[group]; ok {
+				filtered = append(filtered, item)
+				break
+			}
+		}
+	}
+	return filtered
+}
+
 func GetPricing(c *gin.Context) {
 	pricing := model.GetPricing()
 	userId, exists := c.Get("id")
@@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) {
 	}
 
 	usableGroup = service.GetUserUsableGroups(group)
+	pricing = filterPricingByUsableGroups(pricing, usableGroup)
 	// check groupRatio contains usableGroup
 	for group := range ratio_setting.GetGroupRatioCopy() {
 		if _, ok := usableGroup[group]; !ok {
@@ -46,7 +72,7 @@ func GetPricing(c *gin.Context) {
 		"usable_group":       usableGroup,
 		"supported_endpoint": model.GetSupportedEndpointMap(),
 		"auto_groups":        service.GetUserAutoGroup(group),
-		"_":                  "a42d372ccf0b5dd13ecf71203521f9d2",
+		"pricing_version":    "a42d372ccf0b5dd13ecf71203521f9d2",
 	})
 }
 

+ 24 - 0
controller/rankings.go

@@ -0,0 +1,24 @@
+package controller
+
+import (
+	"net/http"
+
+	"github.com/QuantumNous/new-api/service"
+	"github.com/gin-gonic/gin"
+)
+
+func GetRankings(c *gin.Context) {
+	result, err := service.GetRankingsSnapshot(c.DefaultQuery("period", "week"))
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"data":    result,
+	})
+}

+ 161 - 46
controller/ratio_sync.go

@@ -21,14 +21,16 @@ import (
 
 	"github.com/QuantumNous/new-api/dto"
 	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/setting/billing_setting"
 	"github.com/QuantumNous/new-api/setting/ratio_setting"
+	"github.com/samber/lo"
 
 	"github.com/gin-gonic/gin"
 )
 
 const (
 	defaultTimeoutSeconds       = 10
-	defaultEndpoint             = "/api/ratio_config"
+	defaultEndpoint             = "/api/pricing"
 	maxConcurrentFetches        = 8
 	maxRatioConfigBytes         = 10 << 20 // 10MB
 	floatEpsilon                = 1e-9
@@ -59,7 +61,29 @@ func valuesEqual(a, b interface{}) bool {
 	return a == b
 }
 
-var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"}
+var pricingSyncFields = []string{
+	"model_ratio",
+	"completion_ratio",
+	"cache_ratio",
+	"create_cache_ratio",
+	"image_ratio",
+	"audio_ratio",
+	"audio_completion_ratio",
+	"model_price",
+	billing_setting.BillingModeField,
+	billing_setting.BillingExprField,
+}
+
+var numericPricingSyncFields = map[string]bool{
+	"model_ratio":            true,
+	"completion_ratio":       true,
+	"cache_ratio":            true,
+	"create_cache_ratio":     true,
+	"image_ratio":            true,
+	"audio_ratio":            true,
+	"audio_completion_ratio": true,
+	"model_price":            true,
+}
 
 type upstreamResult struct {
 	Name string         `json:"name"`
@@ -67,6 +91,54 @@ type upstreamResult struct {
 	Err  string         `json:"err,omitempty"`
 }
 
+func valueMap(value any) map[string]any {
+	switch typed := value.(type) {
+	case map[string]any:
+		return typed
+	case map[string]float64:
+		return lo.MapValues(typed, func(value float64, _ string) any { return value })
+	case map[string]string:
+		return lo.MapValues(typed, func(value string, _ string) any { return value })
+	default:
+		return nil
+	}
+}
+
+func asFloat64(value any) (float64, bool) {
+	switch typed := value.(type) {
+	case float64:
+		return typed, true
+	case float32:
+		return float64(typed), true
+	case int:
+		return float64(typed), true
+	case int64:
+		return float64(typed), true
+	case json.Number:
+		parsed, err := typed.Float64()
+		return parsed, err == nil
+	default:
+		return 0, false
+	}
+}
+
+func normalizeSyncValue(field string, value any) any {
+	if numericPricingSyncFields[field] {
+		if parsed, ok := asFloat64(value); ok {
+			return parsed
+		}
+	}
+	return value
+}
+
+func getLocalPricingSyncData() map[string]any {
+	data := billing_setting.GetPricingSyncData(map[string]any(ratio_setting.GetExposedData()))
+	data["image_ratio"] = ratio_setting.GetImageRatioCopy()
+	data["audio_ratio"] = ratio_setting.GetAudioRatioCopy()
+	data["audio_completion_ratio"] = ratio_setting.GetAudioCompletionRatioCopy()
+	return data
+}
+
 func FetchUpstreamRatios(c *gin.Context) {
 	var req dto.UpstreamRequest
 	if err := c.ShouldBindJSON(&req); err != nil {
@@ -293,7 +365,7 @@ func FetchUpstreamRatios(c *gin.Context) {
 			if err := common.Unmarshal(body.Data, &type1Data); err == nil {
 				// 如果包含至少一个 ratioTypes 字段,则认为是 type1
 				isType1 := false
-				for _, rt := range ratioTypes {
+				for _, rt := range pricingSyncFields {
 					if _, ok := type1Data[rt]; ok {
 						isType1 = true
 						break
@@ -307,11 +379,18 @@ func FetchUpstreamRatios(c *gin.Context) {
 
 			// 如果不是 type1,则尝试按 type2 (/api/pricing) 解析
 			var pricingItems []struct {
-				ModelName       string  `json:"model_name"`
-				QuotaType       int     `json:"quota_type"`
-				ModelRatio      float64 `json:"model_ratio"`
-				ModelPrice      float64 `json:"model_price"`
-				CompletionRatio float64 `json:"completion_ratio"`
+				ModelName            string   `json:"model_name"`
+				QuotaType            int      `json:"quota_type"`
+				ModelRatio           float64  `json:"model_ratio"`
+				ModelPrice           float64  `json:"model_price"`
+				CompletionRatio      float64  `json:"completion_ratio"`
+				CacheRatio           *float64 `json:"cache_ratio"`
+				CreateCacheRatio     *float64 `json:"create_cache_ratio"`
+				ImageRatio           *float64 `json:"image_ratio"`
+				AudioRatio           *float64 `json:"audio_ratio"`
+				AudioCompletionRatio *float64 `json:"audio_completion_ratio"`
+				BillingMode          string   `json:"billing_mode"`
+				BillingExpr          string   `json:"billing_expr"`
 			}
 			if err := common.Unmarshal(body.Data, &pricingItems); err != nil {
 				logger.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error())
@@ -321,9 +400,23 @@ func FetchUpstreamRatios(c *gin.Context) {
 
 			modelRatioMap := make(map[string]float64)
 			completionRatioMap := make(map[string]float64)
+			cacheRatioMap := make(map[string]float64)
+			createCacheRatioMap := make(map[string]float64)
+			imageRatioMap := make(map[string]float64)
+			audioRatioMap := make(map[string]float64)
+			audioCompletionRatioMap := make(map[string]float64)
 			modelPriceMap := make(map[string]float64)
+			billingModeMap := make(map[string]string)
+			billingExprMap := make(map[string]string)
 
 			for _, item := range pricingItems {
+				if item.ModelName == "" {
+					continue
+				}
+				if item.BillingMode == billing_setting.BillingModeTieredExpr && strings.TrimSpace(item.BillingExpr) != "" {
+					billingModeMap[item.ModelName] = billing_setting.BillingModeTieredExpr
+					billingExprMap[item.ModelName] = item.BillingExpr
+				}
 				if item.QuotaType == 1 {
 					modelPriceMap[item.ModelName] = item.ModelPrice
 				} else {
@@ -331,6 +424,21 @@ func FetchUpstreamRatios(c *gin.Context) {
 					// completionRatio 可能为 0,此时也直接赋值,保持与上游一致
 					completionRatioMap[item.ModelName] = item.CompletionRatio
 				}
+				if item.CacheRatio != nil {
+					cacheRatioMap[item.ModelName] = *item.CacheRatio
+				}
+				if item.CreateCacheRatio != nil {
+					createCacheRatioMap[item.ModelName] = *item.CreateCacheRatio
+				}
+				if item.ImageRatio != nil {
+					imageRatioMap[item.ModelName] = *item.ImageRatio
+				}
+				if item.AudioRatio != nil {
+					audioRatioMap[item.ModelName] = *item.AudioRatio
+				}
+				if item.AudioCompletionRatio != nil {
+					audioCompletionRatioMap[item.ModelName] = *item.AudioCompletionRatio
+				}
 			}
 
 			converted := make(map[string]any)
@@ -350,6 +458,21 @@ func FetchUpstreamRatios(c *gin.Context) {
 				}
 				converted["completion_ratio"] = compAny
 			}
+			if len(cacheRatioMap) > 0 {
+				converted["cache_ratio"] = valueMap(cacheRatioMap)
+			}
+			if len(createCacheRatioMap) > 0 {
+				converted["create_cache_ratio"] = valueMap(createCacheRatioMap)
+			}
+			if len(imageRatioMap) > 0 {
+				converted["image_ratio"] = valueMap(imageRatioMap)
+			}
+			if len(audioRatioMap) > 0 {
+				converted["audio_ratio"] = valueMap(audioRatioMap)
+			}
+			if len(audioCompletionRatioMap) > 0 {
+				converted["audio_completion_ratio"] = valueMap(audioCompletionRatioMap)
+			}
 
 			if len(modelPriceMap) > 0 {
 				priceAny := make(map[string]any, len(modelPriceMap))
@@ -358,6 +481,12 @@ func FetchUpstreamRatios(c *gin.Context) {
 				}
 				converted["model_price"] = priceAny
 			}
+			if len(billingModeMap) > 0 {
+				converted[billing_setting.BillingModeField] = valueMap(billingModeMap)
+			}
+			if len(billingExprMap) > 0 {
+				converted[billing_setting.BillingExprField] = valueMap(billingExprMap)
+			}
 
 			ch <- upstreamResult{Name: uniqueName, Data: converted}
 		}(chn)
@@ -366,7 +495,7 @@ func FetchUpstreamRatios(c *gin.Context) {
 	wg.Wait()
 	close(ch)
 
-	localData := ratio_setting.GetExposedData()
+	localData := getLocalPricingSyncData()
 
 	var testResults []dto.TestResult
 	var successfulChannels []struct {
@@ -412,22 +541,16 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
 
 	allModels := make(map[string]struct{})
 
-	for _, ratioType := range ratioTypes {
-		if localRatioAny, ok := localData[ratioType]; ok {
-			if localRatio, ok := localRatioAny.(map[string]float64); ok {
-				for modelName := range localRatio {
-					allModels[modelName] = struct{}{}
-				}
-			}
+	for _, field := range pricingSyncFields {
+		for modelName := range valueMap(localData[field]) {
+			allModels[modelName] = struct{}{}
 		}
 	}
 
 	for _, channel := range successfulChannels {
-		for _, ratioType := range ratioTypes {
-			if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
-				for modelName := range upstreamRatio {
-					allModels[modelName] = struct{}{}
-				}
+		for _, field := range pricingSyncFields {
+			for modelName := range valueMap(channel.data[field]) {
+				allModels[modelName] = struct{}{}
 			}
 		}
 	}
@@ -438,10 +561,10 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
 	for _, channel := range successfulChannels {
 		confidenceMap[channel.name] = make(map[string]bool)
 
-		modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any)
-		completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any)
+		modelRatios := valueMap(channel.data["model_ratio"])
+		completionRatios := valueMap(channel.data["completion_ratio"])
 
-		if hasModelRatio && hasCompletionRatio {
+		if len(modelRatios) > 0 && len(completionRatios) > 0 {
 			// 遍历所有模型,检查是否满足不可信条件
 			for modelName := range allModels {
 				// 默认为可信
@@ -451,12 +574,10 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
 				if modelRatioVal, ok := modelRatios[modelName]; ok {
 					if completionRatioVal, ok := completionRatios[modelName]; ok {
 						// 转换为float64进行比较
-						if modelRatioFloat, ok := modelRatioVal.(float64); ok {
-							if completionRatioFloat, ok := completionRatioVal.(float64); ok {
-								if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 {
-									confidenceMap[channel.name][modelName] = false
-								}
-							}
+						modelRatioFloat, modelRatioOK := asFloat64(modelRatioVal)
+						completionRatioFloat, completionRatioOK := asFloat64(completionRatioVal)
+						if modelRatioOK && completionRatioOK && nearlyEqual(modelRatioFloat, 37.5) && nearlyEqual(completionRatioFloat, 1.0) {
+							confidenceMap[channel.name][modelName] = false
 						}
 					}
 				}
@@ -470,14 +591,10 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
 	}
 
 	for modelName := range allModels {
-		for _, ratioType := range ratioTypes {
+		for _, ratioType := range pricingSyncFields {
 			var localValue interface{} = nil
-			if localRatioAny, ok := localData[ratioType]; ok {
-				if localRatio, ok := localRatioAny.(map[string]float64); ok {
-					if val, exists := localRatio[modelName]; exists {
-						localValue = val
-					}
-				}
+			if val, exists := valueMap(localData[ratioType])[modelName]; exists {
+				localValue = normalizeSyncValue(ratioType, val)
 			}
 
 			upstreamValues := make(map[string]interface{})
@@ -488,16 +605,14 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
 			for _, channel := range successfulChannels {
 				var upstreamValue interface{} = nil
 
-				if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
-					if val, exists := upstreamRatio[modelName]; exists {
-						upstreamValue = val
-						hasUpstreamValue = true
+				if val, exists := valueMap(channel.data[ratioType])[modelName]; exists {
+					upstreamValue = normalizeSyncValue(ratioType, val)
+					hasUpstreamValue = true
 
-						if localValue != nil && !valuesEqual(localValue, val) {
-							hasDifference = true
-						} else if valuesEqual(localValue, val) {
-							upstreamValue = "same"
-						}
+					if localValue != nil && !valuesEqual(localValue, upstreamValue) {
+						hasDifference = true
+					} else if valuesEqual(localValue, upstreamValue) {
+						upstreamValue = "same"
 					}
 				}
 				if upstreamValue == nil && localValue == nil {

+ 13 - 4
controller/relay.go

@@ -15,6 +15,7 @@ import (
 	"github.com/QuantumNous/new-api/logger"
 	"github.com/QuantumNous/new-api/middleware"
 	"github.com/QuantumNous/new-api/model"
+	perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
 	"github.com/QuantumNous/new-api/relay"
 	relaycommon "github.com/QuantumNous/new-api/relay/common"
 	relayconstant "github.com/QuantumNous/new-api/relay/constant"
@@ -151,7 +152,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
 
 	priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta)
 	if err != nil {
-		newAPIError = types.NewError(err, types.ErrorCodeModelPriceError)
+		newAPIError = types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest))
 		return
 	}
 
@@ -239,6 +240,11 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
 		retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
 		logger.LogInfo(c, retryLogStr)
 	}
+	if newAPIError != nil {
+		gopool.Go(func() {
+			perfmetrics.RecordRelaySample(relayInfo, false, 0)
+		})
+	}
 }
 
 var upgrader = websocket.Upgrader{
@@ -341,6 +347,9 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
 	if code < 100 || code > 599 {
 		return true
 	}
+	if operation_setting.IsAlwaysSkipRetryCode(openaiErr.GetErrorCode()) {
+		return false
+	}
 	return operation_setting.ShouldRetryByStatusCode(code)
 }
 
@@ -348,7 +357,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
 	logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
 	// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
 	// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
-	if service.ShouldDisableChannel(channelError.ChannelType, err) && channelError.AutoBan {
+	if service.ShouldDisableChannel(err) && channelError.AutoBan {
 		gopool.Go(func() {
 			service.DisableChannel(channelError, err.ErrorWithStatusCode())
 		})
@@ -386,7 +395,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
 			startTime = time.Now()
 		}
 		useTimeSeconds := int(time.Since(startTime).Seconds())
-		model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, false, userGroup, other)
+		model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
 	}
 
 }
@@ -578,7 +587,7 @@ func RelayTask(c *gin.Context) {
 			ModelRatio:      relayInfo.PriceData.ModelRatio,
 			OtherRatios:     relayInfo.PriceData.OtherRatios,
 			OriginModelName: relayInfo.OriginModelName,
-			PerCallBilling:  common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName),
+			PerCallBilling:  common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice,
 		}
 		task.Quota = result.Quota
 		task.Data = result.TaskData

+ 46 - 93
controller/secure_verification.go

@@ -7,18 +7,22 @@ import (
 
 	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/model"
-	passkeysvc "github.com/QuantumNous/new-api/service/passkey"
-	"github.com/QuantumNous/new-api/setting/system_setting"
-
 	"github.com/gin-contrib/sessions"
 	"github.com/gin-gonic/gin"
 )
 
 const (
-	// SecureVerificationSessionKey 安全验证的 session key
-	SecureVerificationSessionKey = "secure_verified_at"
+	// SecureVerificationSessionKey means the user has fully passed secure verification.
+	SecureVerificationSessionKey       = "secure_verified_at"
+	secureVerificationMethodSessionKey = "secure_verified_method"
+	secureVerificationMethod2FA        = "2fa"
+	secureVerificationMethodPasskey    = "passkey"
+	// PasskeyReadySessionKey means WebAuthn finished and /api/verify can finalize step-up verification.
+	PasskeyReadySessionKey = "secure_passkey_ready_at"
 	// SecureVerificationTimeout 验证有效期(秒)
 	SecureVerificationTimeout = 300 // 5分钟
+	// PasskeyReadyTimeout passkey ready 标记有效期(秒)
+	PasskeyReadyTimeout = 60
 )
 
 type UniversalVerifyRequest struct {
@@ -76,6 +80,7 @@ func UniversalVerify(c *gin.Context) {
 	// 根据验证方式进行验证
 	var verified bool
 	var verifyMethod string
+	var err error
 
 	switch req.Method {
 	case "2fa":
@@ -95,10 +100,16 @@ func UniversalVerify(c *gin.Context) {
 			common.ApiError(c, fmt.Errorf("用户未启用Passkey"))
 			return
 		}
-		// Passkey 验证需要先调用 PasskeyVerifyBegin 和 PasskeyVerifyFinish
-		// 这里只是验证 Passkey 验证流程是否已经完成
-		// 实际上,前端应该先调用这两个接口,然后再调用本接口
-		verified = true // Passkey 验证逻辑已在 PasskeyVerifyFinish 中完成
+		// Passkey branch only trusts the short-lived marker written by PasskeyVerifyFinish.
+		verified, err = consumePasskeyReady(c)
+		if err != nil {
+			common.ApiError(c, fmt.Errorf("Passkey 验证状态异常: %v", err))
+			return
+		}
+		if !verified {
+			common.ApiError(c, fmt.Errorf("请先完成 Passkey 验证"))
+			return
+		}
 		verifyMethod = "Passkey"
 
 	default:
@@ -112,10 +123,8 @@ func UniversalVerify(c *gin.Context) {
 	}
 
 	// 验证成功,在 session 中记录时间戳
-	session := sessions.Default(c)
-	now := time.Now().Unix()
-	session.Set(SecureVerificationSessionKey, now)
-	if err := session.Save(); err != nil {
+	now, err := setSecureVerificationSession(c, req.Method)
+	if err != nil {
 		common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
 		return
 	}
@@ -133,94 +142,38 @@ func UniversalVerify(c *gin.Context) {
 	})
 }
 
-// PasskeyVerifyAndSetSession Passkey 验证完成后设置 session
-// 这是一个辅助函数,供 PasskeyVerifyFinish 调用
-func PasskeyVerifyAndSetSession(c *gin.Context) {
+func setSecureVerificationSession(c *gin.Context, method string) (int64, error) {
 	session := sessions.Default(c)
+	session.Delete(PasskeyReadySessionKey)
 	now := time.Now().Unix()
 	session.Set(SecureVerificationSessionKey, now)
-	_ = session.Save()
-}
-
-// PasskeyVerifyForSecure 用于安全验证的 Passkey 验证流程
-// 整合了 begin 和 finish 流程
-func PasskeyVerifyForSecure(c *gin.Context) {
-	if !system_setting.GetPasskeySettings().Enabled {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "管理员未启用 Passkey 登录",
-		})
-		return
-	}
-
-	userId := c.GetInt("id")
-	if userId == 0 {
-		c.JSON(http.StatusUnauthorized, gin.H{
-			"success": false,
-			"message": "未登录",
-		})
-		return
-	}
-
-	user := &model.User{Id: userId}
-	if err := user.FillUserById(); err != nil {
-		common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err))
-		return
-	}
-
-	if user.Status != common.UserStatusEnabled {
-		common.ApiError(c, fmt.Errorf("该用户已被禁用"))
-		return
-	}
-
-	credential, err := model.GetPasskeyByUserID(userId)
-	if err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "该用户尚未绑定 Passkey",
-		})
-		return
+	session.Set(secureVerificationMethodSessionKey, method)
+	if err := session.Save(); err != nil {
+		return 0, err
 	}
+	return now, nil
+}
 
-	wa, err := passkeysvc.BuildWebAuthn(c.Request)
-	if err != nil {
-		common.ApiError(c, err)
-		return
+func consumePasskeyReady(c *gin.Context) (bool, error) {
+	session := sessions.Default(c)
+	readyAtRaw := session.Get(PasskeyReadySessionKey)
+	if readyAtRaw == nil {
+		return false, nil
 	}
 
-	waUser := passkeysvc.NewWebAuthnUser(user, credential)
-	sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey)
-	if err != nil {
-		common.ApiError(c, err)
-		return
+	readyAt, ok := readyAtRaw.(int64)
+	if !ok {
+		session.Delete(PasskeyReadySessionKey)
+		_ = session.Save()
+		return false, fmt.Errorf("无效的 Passkey 验证状态")
 	}
-
-	_, err = wa.FinishLogin(waUser, *sessionData, c.Request)
-	if err != nil {
-		common.ApiError(c, err)
-		return
+	session.Delete(PasskeyReadySessionKey)
+	if err := session.Save(); err != nil {
+		return false, err
 	}
-
-	// 更新凭证的最后使用时间
-	now := time.Now()
-	credential.LastUsedAt = &now
-	if err := model.UpsertPasskeyCredential(credential); err != nil {
-		common.ApiError(c, err)
-		return
+	// Expired ready markers cannot be reused.
+	if time.Now().Unix()-readyAt >= PasskeyReadyTimeout {
+		return false, nil
 	}
-
-	// 验证成功,设置 session
-	PasskeyVerifyAndSetSession(c)
-
-	// 记录日志
-	model.RecordLog(userId, model.LogTypeSystem, "Passkey 安全验证成功")
-
-	c.JSON(http.StatusOK, gin.H{
-		"success": true,
-		"message": "Passkey 验证成功",
-		"data": gin.H{
-			"verified":   true,
-			"expires_at": time.Now().Unix() + SecureVerificationTimeout,
-		},
-	})
+	return true, nil
 }

+ 19 - 16
controller/subscription_payment_creem.go

@@ -2,11 +2,13 @@ package controller
 
 import (
 	"bytes"
+	"fmt"
 	"io"
-	"log"
+	"net/http"
 	"time"
 
 	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/logger"
 	"github.com/QuantumNous/new-api/model"
 	"github.com/QuantumNous/new-api/setting"
 	"github.com/QuantumNous/new-api/setting/operation_setting"
@@ -24,14 +26,14 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
 	// Keep body for debugging consistency (like RequestCreemPay)
 	bodyBytes, err := io.ReadAll(c.Request.Body)
 	if err != nil {
-		log.Printf("read subscription creem pay req body err: %v", err)
-		c.JSON(200, gin.H{"message": "error", "data": "read query error"})
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅支付请求读取失败 error=%q", err.Error()))
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "read query error"})
 		return
 	}
 	c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
 
 	if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
-		c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
 		return
 	}
 
@@ -81,16 +83,17 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
 
 	// create pending order first
 	order := &model.SubscriptionOrder{
-		UserId:        userId,
-		PlanId:        plan.Id,
-		Money:         plan.PriceAmount,
-		TradeNo:       referenceId,
-		PaymentMethod: PaymentMethodCreem,
-		CreateTime:    time.Now().Unix(),
-		Status:        common.TopUpStatusPending,
+		UserId:          userId,
+		PlanId:          plan.Id,
+		Money:           plan.PriceAmount,
+		TradeNo:         referenceId,
+		PaymentMethod:   model.PaymentMethodCreem,
+		PaymentProvider: model.PaymentProviderCreem,
+		CreateTime:      time.Now().Unix(),
+		Status:          common.TopUpStatusPending,
 	}
 	if err := order.Insert(); err != nil {
-		c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
 		return
 	}
 
@@ -112,14 +115,14 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
 		Quota:     0,
 	}
 
-	checkoutUrl, err := genCreemLink(referenceId, product, user.Email, user.Username)
+	checkoutUrl, err := genCreemLink(c.Request.Context(), referenceId, product, user.Email, user.Username)
 	if err != nil {
-		log.Printf("获取Creem支付链接失败: %v", err)
-		c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅支付链接创建失败 trade_no=%s product_id=%s error=%q", referenceId, product.ProductId, err.Error()))
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
 		return
 	}
 
-	c.JSON(200, gin.H{
+	c.JSON(http.StatusOK, gin.H{
 		"message": "success",
 		"data": gin.H{
 			"checkout_url": checkoutUrl,

+ 11 - 10
controller/subscription_payment_epay.go

@@ -82,13 +82,14 @@ func SubscriptionRequestEpay(c *gin.Context) {
 	}
 
 	order := &model.SubscriptionOrder{
-		UserId:        userId,
-		PlanId:        plan.Id,
-		Money:         plan.PriceAmount,
-		TradeNo:       tradeNo,
-		PaymentMethod: req.PaymentMethod,
-		CreateTime:    time.Now().Unix(),
-		Status:        common.TopUpStatusPending,
+		UserId:          userId,
+		PlanId:          plan.Id,
+		Money:           plan.PriceAmount,
+		TradeNo:         tradeNo,
+		PaymentMethod:   req.PaymentMethod,
+		PaymentProvider: model.PaymentProviderEpay,
+		CreateTime:      time.Now().Unix(),
+		Status:          common.TopUpStatusPending,
 	}
 	if err := order.Insert(); err != nil {
 		common.ApiErrorMsg(c, "创建订单失败")
@@ -104,7 +105,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
 		ReturnUrl:      returnUrl,
 	})
 	if err != nil {
-		_ = model.ExpireSubscriptionOrder(tradeNo)
+		_ = model.ExpireSubscriptionOrder(tradeNo, model.PaymentProviderEpay)
 		common.ApiErrorMsg(c, "拉起支付失败")
 		return
 	}
@@ -156,7 +157,7 @@ func SubscriptionEpayNotify(c *gin.Context) {
 	LockOrder(verifyInfo.ServiceTradeNo)
 	defer UnlockOrder(verifyInfo.ServiceTradeNo)
 
-	if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil {
+	if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), model.PaymentProviderEpay, verifyInfo.Type); err != nil {
 		_, _ = c.Writer.Write([]byte("fail"))
 		return
 	}
@@ -205,7 +206,7 @@ func SubscriptionEpayReturn(c *gin.Context) {
 	if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
 		LockOrder(verifyInfo.ServiceTradeNo)
 		defer UnlockOrder(verifyInfo.ServiceTradeNo)
-		if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil {
+		if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), model.PaymentProviderEpay, verifyInfo.Type); err != nil {
 			c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
 			return
 		}

+ 10 - 9
controller/subscription_payment_stripe.go

@@ -2,12 +2,12 @@ package controller
 
 import (
 	"fmt"
-	"log"
 	"net/http"
 	"strings"
 	"time"
 
 	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/logger"
 	"github.com/QuantumNous/new-api/model"
 	"github.com/QuantumNous/new-api/setting"
 	"github.com/QuantumNous/new-api/setting/system_setting"
@@ -78,19 +78,20 @@ func SubscriptionRequestStripePay(c *gin.Context) {
 
 	payLink, err := genStripeSubscriptionLink(referenceId, user.StripeCustomer, user.Email, plan.StripePriceId)
 	if err != nil {
-		log.Println("获取Stripe Checkout支付链接失败", err)
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 订阅支付链接创建失败 trade_no=%s plan_id=%d error=%q", referenceId, plan.Id, err.Error()))
 		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
 		return
 	}
 
 	order := &model.SubscriptionOrder{
-		UserId:        userId,
-		PlanId:        plan.Id,
-		Money:         plan.PriceAmount,
-		TradeNo:       referenceId,
-		PaymentMethod: PaymentMethodStripe,
-		CreateTime:    time.Now().Unix(),
-		Status:        common.TopUpStatusPending,
+		UserId:          userId,
+		PlanId:          plan.Id,
+		Money:           plan.PriceAmount,
+		TradeNo:         referenceId,
+		PaymentMethod:   model.PaymentMethodStripe,
+		PaymentProvider: model.PaymentProviderStripe,
+		CreateTime:      time.Now().Unix(),
+		Status:          common.TopUpStatusPending,
 	}
 	if err := order.Insert(); err != nil {
 		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})

+ 313 - 0
controller/task_video.go

@@ -0,0 +1,313 @@
+package controller
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/constant"
+	"github.com/QuantumNous/new-api/dto"
+	"github.com/QuantumNous/new-api/logger"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/relay"
+	"github.com/QuantumNous/new-api/relay/channel"
+	relaycommon "github.com/QuantumNous/new-api/relay/common"
+	"github.com/QuantumNous/new-api/setting/ratio_setting"
+)
+
+func UpdateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
+	for channelId, taskIds := range taskChannelM {
+		if err := updateVideoTaskAll(ctx, platform, channelId, taskIds, taskM); err != nil {
+			logger.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
+		}
+	}
+	return nil
+}
+
+func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error {
+	logger.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
+	if len(taskIds) == 0 {
+		return nil
+	}
+	cacheGetChannel, err := model.CacheGetChannel(channelId)
+	if err != nil {
+		errUpdate := model.TaskBulkUpdate(taskIds, map[string]any{
+			"fail_reason": fmt.Sprintf("Failed to get channel info, channel ID: %d", channelId),
+			"status":      "FAILURE",
+			"progress":    "100%",
+		})
+		if errUpdate != nil {
+			common.SysLog(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate))
+		}
+		return fmt.Errorf("CacheGetChannel failed: %w", err)
+	}
+	adaptor := relay.GetTaskAdaptor(platform)
+	if adaptor == nil {
+		return fmt.Errorf("video adaptor not found")
+	}
+	info := &relaycommon.RelayInfo{}
+	info.ChannelMeta = &relaycommon.ChannelMeta{
+		ChannelBaseUrl: cacheGetChannel.GetBaseURL(),
+	}
+	info.ApiKey = cacheGetChannel.Key
+	adaptor.Init(info)
+	for _, taskId := range taskIds {
+		if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
+			logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
+		}
+	}
+	return nil
+}
+
+func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, channel *model.Channel, taskId string, taskM map[string]*model.Task) error {
+	baseURL := constant.ChannelBaseURLs[channel.Type]
+	if channel.GetBaseURL() != "" {
+		baseURL = channel.GetBaseURL()
+	}
+	proxy := channel.GetSetting().Proxy
+
+	task := taskM[taskId]
+	if task == nil {
+		logger.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
+		return fmt.Errorf("task %s not found", taskId)
+	}
+	key := channel.Key
+
+	privateData := task.PrivateData
+	if privateData.Key != "" {
+		key = privateData.Key
+	}
+	resp, err := adaptor.FetchTask(baseURL, key, map[string]any{
+		"task_id": taskId,
+		"action":  task.Action,
+	}, proxy)
+	if err != nil {
+		return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err)
+	}
+	//if resp.StatusCode != http.StatusOK {
+	//return fmt.Errorf("get Video Task status code: %d", resp.StatusCode)
+	//}
+	defer resp.Body.Close()
+	responseBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
+	}
+
+	logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask response: %s", string(responseBody)))
+
+	taskResult := &relaycommon.TaskInfo{}
+	// try parse as New API response format
+	var responseItems dto.TaskResponse[model.Task]
+	if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
+		logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask parsed as new api response format: %+v", responseItems))
+		t := responseItems.Data
+		taskResult.TaskID = t.TaskID
+		taskResult.Status = string(t.Status)
+		taskResult.Url = t.FailReason
+		taskResult.Progress = t.Progress
+		taskResult.Reason = t.FailReason
+		task.Data = t.Data
+	} else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil {
+		return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
+	} else {
+		task.Data = redactVideoResponseBody(responseBody)
+	}
+
+	logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask taskResult: %+v", taskResult))
+
+	now := time.Now().Unix()
+	if taskResult.Status == "" {
+		//return fmt.Errorf("task %s status is empty", taskId)
+		taskResult = relaycommon.FailTaskInfo("upstream returned empty status")
+	}
+
+	// 记录原本的状态,防止重复退款
+	shouldRefund := false
+	quota := task.Quota
+	preStatus := task.Status
+
+	task.Status = model.TaskStatus(taskResult.Status)
+	switch taskResult.Status {
+	case model.TaskStatusSubmitted:
+		task.Progress = "10%"
+	case model.TaskStatusQueued:
+		task.Progress = "20%"
+	case model.TaskStatusInProgress:
+		task.Progress = "30%"
+		if task.StartTime == 0 {
+			task.StartTime = now
+		}
+	case model.TaskStatusSuccess:
+		task.Progress = "100%"
+		if task.FinishTime == 0 {
+			task.FinishTime = now
+		}
+		if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") {
+			task.FailReason = taskResult.Url
+		}
+
+		// 如果返回了 total_tokens 并且配置了模型倍率(非固定价格),则重新计费
+		if taskResult.TotalTokens > 0 {
+			// 获取模型名称
+			var taskData map[string]interface{}
+			if err := json.Unmarshal(task.Data, &taskData); err == nil {
+				if modelName, ok := taskData["model"].(string); ok && modelName != "" {
+					// 获取模型价格和倍率
+					modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName)
+					// 只有配置了倍率(非固定价格)时才按 token 重新计费
+					if hasRatioSetting && modelRatio > 0 {
+						// 获取用户和组的倍率信息
+						group := task.Group
+						if group == "" {
+							user, err := model.GetUserById(task.UserId, false)
+							if err == nil {
+								group = user.Group
+							}
+						}
+						if group != "" {
+							groupRatio := ratio_setting.GetGroupRatio(group)
+							userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(group, group)
+
+							var finalGroupRatio float64
+							if hasUserGroupRatio {
+								finalGroupRatio = userGroupRatio
+							} else {
+								finalGroupRatio = groupRatio
+							}
+
+							// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
+							actualQuota := int(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio)
+
+							// 计算差额
+							preConsumedQuota := task.Quota
+							quotaDelta := actualQuota - preConsumedQuota
+
+							if quotaDelta > 0 {
+								// 需要补扣费
+								logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后补扣费:%s(实际消耗:%s,预扣费:%s,tokens:%d)",
+									task.TaskID,
+									logger.LogQuota(quotaDelta),
+									logger.LogQuota(actualQuota),
+									logger.LogQuota(preConsumedQuota),
+									taskResult.TotalTokens,
+								))
+								if err := model.DecreaseUserQuota(task.UserId, quotaDelta, false); err != nil {
+									logger.LogError(ctx, fmt.Sprintf("补扣费失败: %s", err.Error()))
+								} else {
+									model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta)
+									model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
+									task.Quota = actualQuota // 更新任务记录的实际扣费额度
+
+									// 记录消费日志
+									logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,补扣费 %s",
+										modelRatio, finalGroupRatio, taskResult.TotalTokens,
+										logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(quotaDelta))
+									model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
+								}
+							} else if quotaDelta < 0 {
+								// 需要退还多扣的费用
+								refundQuota := -quotaDelta
+								logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后返还:%s(实际消耗:%s,预扣费:%s,tokens:%d)",
+									task.TaskID,
+									logger.LogQuota(refundQuota),
+									logger.LogQuota(actualQuota),
+									logger.LogQuota(preConsumedQuota),
+									taskResult.TotalTokens,
+								))
+								if err := model.IncreaseUserQuota(task.UserId, refundQuota, false); err != nil {
+									logger.LogError(ctx, fmt.Sprintf("退还预扣费失败: %s", err.Error()))
+								} else {
+									task.Quota = actualQuota // 更新任务记录的实际扣费额度
+
+									// 记录退款日志
+									logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,退还 %s",
+										modelRatio, finalGroupRatio, taskResult.TotalTokens,
+										logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(refundQuota))
+									model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
+								}
+							} else {
+								// quotaDelta == 0, 预扣费刚好准确
+								logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费准确(%s,tokens:%d)",
+									task.TaskID, logger.LogQuota(actualQuota), taskResult.TotalTokens))
+							}
+						}
+					}
+				}
+			}
+		}
+	case model.TaskStatusFailure:
+		logger.LogJson(ctx, fmt.Sprintf("Task %s failed", taskId), task)
+		task.Status = model.TaskStatusFailure
+		task.Progress = "100%"
+		if task.FinishTime == 0 {
+			task.FinishTime = now
+		}
+		task.FailReason = taskResult.Reason
+		logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
+		taskResult.Progress = "100%"
+		if quota != 0 {
+			if preStatus != model.TaskStatusFailure {
+				shouldRefund = true
+			} else {
+				logger.LogWarn(ctx, fmt.Sprintf("Task %s already in failure status, skip refund", task.TaskID))
+			}
+		}
+	default:
+		return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, taskId)
+	}
+	if taskResult.Progress != "" {
+		task.Progress = taskResult.Progress
+	}
+	if err := task.Update(); err != nil {
+		common.SysLog("UpdateVideoTask task error: " + err.Error())
+		shouldRefund = false
+	}
+
+	if shouldRefund {
+		// 任务失败且之前状态不是失败才退还额度,防止重复退还
+		if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil {
+			logger.LogWarn(ctx, "Failed to increase user quota: "+err.Error())
+		}
+		logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, logger.LogQuota(quota))
+		model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
+	}
+
+	return nil
+}
+
+func redactVideoResponseBody(body []byte) []byte {
+	var m map[string]any
+	if err := json.Unmarshal(body, &m); err != nil {
+		return body
+	}
+	resp, _ := m["response"].(map[string]any)
+	if resp != nil {
+		delete(resp, "bytesBase64Encoded")
+		if v, ok := resp["video"].(string); ok {
+			resp["video"] = truncateBase64(v)
+		}
+		if vs, ok := resp["videos"].([]any); ok {
+			for i := range vs {
+				if vm, ok := vs[i].(map[string]any); ok {
+					delete(vm, "bytesBase64Encoded")
+				}
+			}
+		}
+	}
+	b, err := json.Marshal(m)
+	if err != nil {
+		return body
+	}
+	return b
+}
+
+func truncateBase64(s string) string {
+	const maxKeep = 256
+	if len(s) <= maxKeep {
+		return s
+	}
+	return s[:maxKeep] + "..."
+}

+ 60 - 12
controller/token.go

@@ -14,6 +14,23 @@ import (
 	"github.com/gin-gonic/gin"
 )
 
+func buildMaskedTokenResponse(token *model.Token) *model.Token {
+	if token == nil {
+		return nil
+	}
+	maskedToken := *token
+	maskedToken.Key = token.GetMaskedKey()
+	return &maskedToken
+}
+
+func buildMaskedTokenResponses(tokens []*model.Token) []*model.Token {
+	maskedTokens := make([]*model.Token, 0, len(tokens))
+	for _, token := range tokens {
+		maskedTokens = append(maskedTokens, buildMaskedTokenResponse(token))
+	}
+	return maskedTokens
+}
+
 func GetAllTokens(c *gin.Context) {
 	userId := c.GetInt("id")
 	pageInfo := common.GetPageQuery(c)
@@ -24,9 +41,8 @@ func GetAllTokens(c *gin.Context) {
 	}
 	total, _ := model.CountUserTokens(userId)
 	pageInfo.SetTotal(int(total))
-	pageInfo.SetItems(tokens)
+	pageInfo.SetItems(buildMaskedTokenResponses(tokens))
 	common.ApiSuccess(c, pageInfo)
-	return
 }
 
 func SearchTokens(c *gin.Context) {
@@ -42,9 +58,8 @@ func SearchTokens(c *gin.Context) {
 		return
 	}
 	pageInfo.SetTotal(int(total))
-	pageInfo.SetItems(tokens)
+	pageInfo.SetItems(buildMaskedTokenResponses(tokens))
 	common.ApiSuccess(c, pageInfo)
-	return
 }
 
 func GetToken(c *gin.Context) {
@@ -59,12 +74,24 @@ func GetToken(c *gin.Context) {
 		common.ApiError(c, err)
 		return
 	}
-	c.JSON(http.StatusOK, gin.H{
-		"success": true,
-		"message": "",
-		"data":    token,
+	common.ApiSuccess(c, buildMaskedTokenResponse(token))
+}
+
+func GetTokenKey(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	userId := c.GetInt("id")
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	token, err := model.GetTokenByIds(id, userId)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	common.ApiSuccess(c, gin.H{
+		"key": token.GetFullKey(),
 	})
-	return
 }
 
 func GetTokenStatus(c *gin.Context) {
@@ -204,7 +231,6 @@ func AddToken(c *gin.Context) {
 		"success": true,
 		"message": "",
 	})
-	return
 }
 
 func DeleteToken(c *gin.Context) {
@@ -219,7 +245,6 @@ func DeleteToken(c *gin.Context) {
 		"success": true,
 		"message": "",
 	})
-	return
 }
 
 func UpdateToken(c *gin.Context) {
@@ -283,7 +308,7 @@ func UpdateToken(c *gin.Context) {
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
-		"data":    cleanToken,
+		"data":    buildMaskedTokenResponse(cleanToken),
 	})
 }
 
@@ -309,3 +334,26 @@ func DeleteTokenBatch(c *gin.Context) {
 		"data":    count,
 	})
 }
+
+func GetTokenKeysBatch(c *gin.Context) {
+	tokenBatch := TokenBatch{}
+	if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
+		common.ApiErrorI18n(c, i18n.MsgInvalidParams)
+		return
+	}
+	if len(tokenBatch.Ids) > 100 {
+		common.ApiErrorI18n(c, i18n.MsgBatchTooMany, map[string]any{"Max": 100})
+		return
+	}
+	userId := c.GetInt("id")
+	tokens, err := model.GetTokenKeysByIds(tokenBatch.Ids, userId)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	keysMap := make(map[int]string)
+	for _, t := range tokens {
+		keysMap[t.Id] = t.GetFullKey()
+	}
+	common.ApiSuccess(c, gin.H{"keys": keysMap})
+}

+ 541 - 0
controller/token_test.go

@@ -0,0 +1,541 @@
+package controller
+
+import (
+	"bytes"
+	"database/sql"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/gin-gonic/gin"
+	"github.com/glebarez/sqlite"
+	"gorm.io/driver/mysql"
+	"gorm.io/driver/postgres"
+	"gorm.io/gorm"
+)
+
+type tokenAPIResponse struct {
+	Success bool            `json:"success"`
+	Message string          `json:"message"`
+	Data    json.RawMessage `json:"data"`
+}
+
+type tokenPageResponse struct {
+	Items []tokenResponseItem `json:"items"`
+}
+
+type tokenResponseItem struct {
+	ID     int    `json:"id"`
+	Name   string `json:"name"`
+	Key    string `json:"key"`
+	Status int    `json:"status"`
+}
+
+type tokenKeyResponse struct {
+	Key string `json:"key"`
+}
+
+type sqliteColumnInfo struct {
+	Name string `gorm:"column:name"`
+	Type string `gorm:"column:type"`
+}
+
+type legacyToken struct {
+	Id                 int            `gorm:"primaryKey"`
+	UserId             int            `gorm:"index"`
+	Key                string         `gorm:"column:key;type:char(48);uniqueIndex"`
+	Status             int            `gorm:"default:1"`
+	Name               string         `gorm:"index"`
+	CreatedTime        int64          `gorm:"bigint"`
+	AccessedTime       int64          `gorm:"bigint"`
+	ExpiredTime        int64          `gorm:"bigint;default:-1"`
+	RemainQuota        int            `gorm:"default:0"`
+	UnlimitedQuota     bool
+	ModelLimitsEnabled bool
+	ModelLimits        string         `gorm:"type:text"`
+	AllowIps           *string        `gorm:"default:''"`
+	UsedQuota          int            `gorm:"default:0"`
+	Group              string         `gorm:"column:group;default:''"`
+	CrossGroupRetry    bool
+	DeletedAt          gorm.DeletedAt `gorm:"index"`
+}
+
+func (legacyToken) TableName() string {
+	return "tokens"
+}
+
+func openTokenControllerTestDB(t *testing.T) *gorm.DB {
+	t.Helper()
+
+	gin.SetMode(gin.TestMode)
+	common.UsingSQLite = true
+	common.UsingMySQL = false
+	common.UsingPostgreSQL = false
+	common.RedisEnabled = false
+
+	dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
+	db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("failed to open sqlite db: %v", err)
+	}
+	model.DB = db
+	model.LOG_DB = db
+
+	t.Cleanup(func() {
+		sqlDB, err := db.DB()
+		if err == nil {
+			_ = sqlDB.Close()
+		}
+	})
+
+	return db
+}
+
+func migrateTokenControllerTestDB(t *testing.T, db *gorm.DB) {
+	t.Helper()
+
+	if err := db.AutoMigrate(&model.Token{}); err != nil {
+		t.Fatalf("failed to migrate token table: %v", err)
+	}
+}
+
+func setupTokenControllerTestDB(t *testing.T) *gorm.DB {
+	t.Helper()
+
+	db := openTokenControllerTestDB(t)
+	migrateTokenControllerTestDB(t, db)
+	return db
+}
+
+func openTokenControllerExternalDB(t *testing.T, dialect string, dsn string) (*gorm.DB, *bool) {
+	t.Helper()
+
+	gin.SetMode(gin.TestMode)
+	common.RedisEnabled = false
+	common.UsingSQLite = false
+	common.UsingMySQL = dialect == "mysql"
+	common.UsingPostgreSQL = dialect == "postgres"
+
+	var (
+		db  *gorm.DB
+		err error
+	)
+	switch dialect {
+	case "mysql":
+		db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
+	case "postgres":
+		db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
+	default:
+		t.Fatalf("unsupported dialect %q", dialect)
+	}
+	if err != nil {
+		t.Fatalf("failed to open %s db: %v", dialect, err)
+	}
+
+	model.DB = db
+	model.LOG_DB = db
+
+	if db.Migrator().HasTable("tokens") {
+		t.Skipf("refusing to run %s migration compatibility test against external database because tokens table already exists", dialect)
+	}
+
+	managedTokensTable := new(bool)
+
+	t.Cleanup(func() {
+		if *managedTokensTable && db.Migrator().HasTable("tokens") {
+			_ = db.Migrator().DropTable("tokens")
+		}
+		sqlDB, err := db.DB()
+		if err == nil {
+			_ = sqlDB.Close()
+		}
+	})
+
+	return db, managedTokensTable
+}
+
+func seedToken(t *testing.T, db *gorm.DB, userID int, name string, rawKey string) *model.Token {
+	t.Helper()
+
+	token := &model.Token{
+		UserId:         userID,
+		Name:           name,
+		Key:            rawKey,
+		Status:         common.TokenStatusEnabled,
+		CreatedTime:    1,
+		AccessedTime:   1,
+		ExpiredTime:    -1,
+		RemainQuota:    100,
+		UnlimitedQuota: true,
+		Group:          "default",
+	}
+	if err := db.Create(token).Error; err != nil {
+		t.Fatalf("failed to create token: %v", err)
+	}
+	return token
+}
+
+func newAuthenticatedContext(t *testing.T, method string, target string, body any, userID int) (*gin.Context, *httptest.ResponseRecorder) {
+	t.Helper()
+
+	var requestBody *bytes.Reader
+	if body != nil {
+		payload, err := common.Marshal(body)
+		if err != nil {
+			t.Fatalf("failed to marshal request body: %v", err)
+		}
+		requestBody = bytes.NewReader(payload)
+	} else {
+		requestBody = bytes.NewReader(nil)
+	}
+
+	recorder := httptest.NewRecorder()
+	ctx, _ := gin.CreateTestContext(recorder)
+	ctx.Request = httptest.NewRequest(method, target, requestBody)
+	if body != nil {
+		ctx.Request.Header.Set("Content-Type", "application/json")
+	}
+	ctx.Set("id", userID)
+	return ctx, recorder
+}
+
+func decodeAPIResponse(t *testing.T, recorder *httptest.ResponseRecorder) tokenAPIResponse {
+	t.Helper()
+
+	var response tokenAPIResponse
+	if err := common.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
+		t.Fatalf("failed to decode api response: %v", err)
+	}
+	return response
+}
+
+func getSQLiteColumnType(t *testing.T, db *gorm.DB, tableName string, columnName string) string {
+	t.Helper()
+
+	var columns []sqliteColumnInfo
+	if err := db.Raw("PRAGMA table_info(" + tableName + ")").Scan(&columns).Error; err != nil {
+		t.Fatalf("failed to inspect %s schema: %v", tableName, err)
+	}
+
+	for _, column := range columns {
+		if column.Name == columnName {
+			return strings.ToLower(column.Type)
+		}
+	}
+
+	t.Fatalf("column %s not found in %s schema", columnName, tableName)
+	return ""
+}
+
+func getTokenKeyColumnType(t *testing.T, db *gorm.DB, dialect string) string {
+	t.Helper()
+
+	switch dialect {
+	case "sqlite":
+		return getSQLiteColumnType(t, db, "tokens", "key")
+	case "mysql":
+		var columnType string
+		if err := db.Raw(`SELECT COLUMN_TYPE FROM information_schema.columns
+			WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`,
+			"tokens", "key").Scan(&columnType).Error; err != nil {
+			t.Fatalf("failed to inspect mysql token key column: %v", err)
+		}
+		return strings.ToLower(columnType)
+	case "postgres":
+		var dataType string
+		var maxLength sql.NullInt64
+		if err := db.Raw(`SELECT data_type, character_maximum_length
+			FROM information_schema.columns
+			WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?`,
+			"tokens", "key").Row().Scan(&dataType, &maxLength); err != nil {
+			t.Fatalf("failed to inspect postgres token key column: %v", err)
+		}
+		switch strings.ToLower(dataType) {
+		case "character varying":
+			return fmt.Sprintf("varchar(%d)", maxLength.Int64)
+		case "character":
+			return fmt.Sprintf("char(%d)", maxLength.Int64)
+		default:
+			if maxLength.Valid {
+				return fmt.Sprintf("%s(%d)", strings.ToLower(dataType), maxLength.Int64)
+			}
+			return strings.ToLower(dataType)
+		}
+	default:
+		t.Fatalf("unsupported dialect %q", dialect)
+		return ""
+	}
+}
+
+func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect string, managedTokensTable *bool) {
+	t.Helper()
+
+	legacyKey := strings.Repeat("a", 48)
+	longKey := strings.Repeat("b", 64)
+
+	if err := db.AutoMigrate(&legacyToken{}); err != nil {
+		t.Fatalf("failed to create legacy token schema: %v", err)
+	}
+	if managedTokensTable != nil {
+		*managedTokensTable = true
+	}
+	if err := db.Create(&legacyToken{
+		UserId:             7,
+		Key:                legacyKey,
+		Status:             common.TokenStatusEnabled,
+		Name:               "legacy-token",
+		CreatedTime:        1,
+		AccessedTime:       1,
+		ExpiredTime:        -1,
+		RemainQuota:        100,
+		UnlimitedQuota:     true,
+		ModelLimitsEnabled: false,
+		ModelLimits:        "",
+		AllowIps:           common.GetPointer(""),
+		UsedQuota:          0,
+		Group:              "default",
+		CrossGroupRetry:    false,
+	}).Error; err != nil {
+		t.Fatalf("failed to seed legacy token row: %v", err)
+	}
+
+	if got := getTokenKeyColumnType(t, db, dialect); got != "char(48)" {
+		t.Fatalf("expected legacy key column type char(48), got %q", got)
+	}
+
+	migrateTokenControllerTestDB(t, db)
+
+	if got := getTokenKeyColumnType(t, db, dialect); got != "varchar(128)" {
+		t.Fatalf("expected migrated key column type varchar(128), got %q", got)
+	}
+
+	var migratedToken model.Token
+	if err := db.First(&migratedToken, "name = ?", "legacy-token").Error; err != nil {
+		t.Fatalf("failed to load migrated token row: %v", err)
+	}
+	if migratedToken.Key != legacyKey {
+		t.Fatalf("expected migrated token key %q, got %q", legacyKey, migratedToken.Key)
+	}
+	if migratedToken.Name != "legacy-token" {
+		t.Fatalf("expected migrated token name to be preserved, got %q", migratedToken.Name)
+	}
+
+	inserted := model.Token{
+		UserId:             8,
+		Name:               "long-token",
+		Key:                longKey,
+		Status:             common.TokenStatusEnabled,
+		CreatedTime:        1,
+		AccessedTime:       1,
+		ExpiredTime:        -1,
+		RemainQuota:        200,
+		UnlimitedQuota:     true,
+		ModelLimitsEnabled: false,
+		ModelLimits:        "",
+		AllowIps:           common.GetPointer(""),
+		UsedQuota:          0,
+		Group:              "default",
+		CrossGroupRetry:    false,
+	}
+	if err := db.Create(&inserted).Error; err != nil {
+		t.Fatalf("failed to insert long token after migration: %v", err)
+	}
+
+	var fetched model.Token
+	if err := db.First(&fetched, "id = ?", inserted.Id).Error; err != nil {
+		t.Fatalf("failed to fetch long token after migration: %v", err)
+	}
+	if fetched.Key != longKey {
+		t.Fatalf("expected long token key %q, got %q", longKey, fetched.Key)
+	}
+}
+
+func TestTokenAutoMigrateUsesVarchar128KeyColumn(t *testing.T) {
+	db := setupTokenControllerTestDB(t)
+
+	if got := getTokenKeyColumnType(t, db, "sqlite"); got != "varchar(128)" {
+		t.Fatalf("expected key column type varchar(128), got %q", got)
+	}
+}
+
+func TestTokenMigrationFromChar48ToVarchar128(t *testing.T) {
+	db := openTokenControllerTestDB(t)
+	runTokenMigrationCompatibilityTest(t, db, "sqlite", nil)
+}
+
+func TestTokenMigrationFromChar48ToVarchar128MySQL(t *testing.T) {
+	dsn := os.Getenv("TEST_MYSQL_DSN")
+	if dsn == "" {
+		t.Skip("set TEST_MYSQL_DSN to run mysql migration compatibility test")
+	}
+
+	db, managedTokensTable := openTokenControllerExternalDB(t, "mysql", dsn)
+	runTokenMigrationCompatibilityTest(t, db, "mysql", managedTokensTable)
+}
+
+func TestTokenMigrationFromChar48ToVarchar128Postgres(t *testing.T) {
+	dsn := os.Getenv("TEST_POSTGRES_DSN")
+	if dsn == "" {
+		t.Skip("set TEST_POSTGRES_DSN to run postgres migration compatibility test")
+	}
+
+	db, managedTokensTable := openTokenControllerExternalDB(t, "postgres", dsn)
+	runTokenMigrationCompatibilityTest(t, db, "postgres", managedTokensTable)
+}
+
+func TestGetAllTokensMasksKeyInResponse(t *testing.T) {
+	db := setupTokenControllerTestDB(t)
+	token := seedToken(t, db, 1, "list-token", "abcd1234efgh5678")
+	seedToken(t, db, 2, "other-user-token", "zzzz1234yyyy5678")
+
+	ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/?p=1&size=10", nil, 1)
+	GetAllTokens(ctx)
+
+	response := decodeAPIResponse(t, recorder)
+	if !response.Success {
+		t.Fatalf("expected success response, got message: %s", response.Message)
+	}
+
+	var page tokenPageResponse
+	if err := common.Unmarshal(response.Data, &page); err != nil {
+		t.Fatalf("failed to decode token page response: %v", err)
+	}
+	if len(page.Items) != 1 {
+		t.Fatalf("expected exactly one token, got %d", len(page.Items))
+	}
+	if page.Items[0].Key != token.GetMaskedKey() {
+		t.Fatalf("expected masked key %q, got %q", token.GetMaskedKey(), page.Items[0].Key)
+	}
+	if strings.Contains(recorder.Body.String(), token.Key) {
+		t.Fatalf("list response leaked raw token key: %s", recorder.Body.String())
+	}
+}
+
+func TestSearchTokensMasksKeyInResponse(t *testing.T) {
+	db := setupTokenControllerTestDB(t)
+	token := seedToken(t, db, 1, "searchable-token", "ijkl1234mnop5678")
+
+	ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/search?keyword=searchable-token&p=1&size=10", nil, 1)
+	SearchTokens(ctx)
+
+	response := decodeAPIResponse(t, recorder)
+	if !response.Success {
+		t.Fatalf("expected success response, got message: %s", response.Message)
+	}
+
+	var page tokenPageResponse
+	if err := common.Unmarshal(response.Data, &page); err != nil {
+		t.Fatalf("failed to decode search response: %v", err)
+	}
+	if len(page.Items) != 1 {
+		t.Fatalf("expected exactly one search result, got %d", len(page.Items))
+	}
+	if page.Items[0].Key != token.GetMaskedKey() {
+		t.Fatalf("expected masked search key %q, got %q", token.GetMaskedKey(), page.Items[0].Key)
+	}
+	if strings.Contains(recorder.Body.String(), token.Key) {
+		t.Fatalf("search response leaked raw token key: %s", recorder.Body.String())
+	}
+}
+
+func TestGetTokenMasksKeyInResponse(t *testing.T) {
+	db := setupTokenControllerTestDB(t)
+	token := seedToken(t, db, 1, "detail-token", "qrst1234uvwx5678")
+
+	ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/"+strconv.Itoa(token.Id), nil, 1)
+	ctx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
+	GetToken(ctx)
+
+	response := decodeAPIResponse(t, recorder)
+	if !response.Success {
+		t.Fatalf("expected success response, got message: %s", response.Message)
+	}
+
+	var detail tokenResponseItem
+	if err := common.Unmarshal(response.Data, &detail); err != nil {
+		t.Fatalf("failed to decode token detail response: %v", err)
+	}
+	if detail.Key != token.GetMaskedKey() {
+		t.Fatalf("expected masked detail key %q, got %q", token.GetMaskedKey(), detail.Key)
+	}
+	if strings.Contains(recorder.Body.String(), token.Key) {
+		t.Fatalf("detail response leaked raw token key: %s", recorder.Body.String())
+	}
+}
+
+func TestUpdateTokenMasksKeyInResponse(t *testing.T) {
+	db := setupTokenControllerTestDB(t)
+	token := seedToken(t, db, 1, "editable-token", "yzab1234cdef5678")
+
+	body := map[string]any{
+		"id":                   token.Id,
+		"name":                 "updated-token",
+		"expired_time":         -1,
+		"remain_quota":         100,
+		"unlimited_quota":      true,
+		"model_limits_enabled": false,
+		"model_limits":         "",
+		"group":                "default",
+		"cross_group_retry":    false,
+	}
+
+	ctx, recorder := newAuthenticatedContext(t, http.MethodPut, "/api/token/", body, 1)
+	UpdateToken(ctx)
+
+	response := decodeAPIResponse(t, recorder)
+	if !response.Success {
+		t.Fatalf("expected success response, got message: %s", response.Message)
+	}
+
+	var detail tokenResponseItem
+	if err := common.Unmarshal(response.Data, &detail); err != nil {
+		t.Fatalf("failed to decode token update response: %v", err)
+	}
+	if detail.Key != token.GetMaskedKey() {
+		t.Fatalf("expected masked update key %q, got %q", token.GetMaskedKey(), detail.Key)
+	}
+	if strings.Contains(recorder.Body.String(), token.Key) {
+		t.Fatalf("update response leaked raw token key: %s", recorder.Body.String())
+	}
+}
+
+func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) {
+	db := setupTokenControllerTestDB(t)
+	token := seedToken(t, db, 1, "owned-token", "owner1234token5678")
+
+	authorizedCtx, authorizedRecorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/"+strconv.Itoa(token.Id)+"/key", nil, 1)
+	authorizedCtx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
+	GetTokenKey(authorizedCtx)
+
+	authorizedResponse := decodeAPIResponse(t, authorizedRecorder)
+	if !authorizedResponse.Success {
+		t.Fatalf("expected authorized key fetch to succeed, got message: %s", authorizedResponse.Message)
+	}
+
+	var keyData tokenKeyResponse
+	if err := common.Unmarshal(authorizedResponse.Data, &keyData); err != nil {
+		t.Fatalf("failed to decode token key response: %v", err)
+	}
+	if keyData.Key != token.GetFullKey() {
+		t.Fatalf("expected full key %q, got %q", token.GetFullKey(), keyData.Key)
+	}
+
+	unauthorizedCtx, unauthorizedRecorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/"+strconv.Itoa(token.Id)+"/key", nil, 2)
+	unauthorizedCtx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
+	GetTokenKey(unauthorizedCtx)
+
+	unauthorizedResponse := decodeAPIResponse(t, unauthorizedRecorder)
+	if unauthorizedResponse.Success {
+		t.Fatalf("expected unauthorized key fetch to fail")
+	}
+	if strings.Contains(unauthorizedRecorder.Body.String(), token.Key) {
+		t.Fatalf("unauthorized key response leaked raw token key: %s", unauthorizedRecorder.Body.String())
+	}
+}

+ 153 - 62
controller/topup.go

@@ -2,7 +2,7 @@ package controller
 
 import (
 	"fmt"
-	"log"
+	"net/http"
 	"net/url"
 	"strconv"
 	"sync"
@@ -27,7 +27,7 @@ func GetTopUpInfo(c *gin.Context) {
 	payMethods := operation_setting.PayMethods
 
 	// 如果启用了 Stripe 支付,添加到支付方法列表
-	if setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "" {
+	if isStripeTopUpEnabled() {
 		// 检查是否已经包含 Stripe
 		hasStripe := false
 		for _, method := range payMethods {
@@ -48,16 +48,69 @@ func GetTopUpInfo(c *gin.Context) {
 		}
 	}
 
+	// 如果启用了 Waffo 支付,添加到支付方法列表
+	enableWaffo := isWaffoTopUpEnabled()
+	if enableWaffo {
+		hasWaffo := false
+		for _, method := range payMethods {
+			if method["type"] == model.PaymentMethodWaffo {
+				hasWaffo = true
+				break
+			}
+		}
+
+		if !hasWaffo {
+			waffoMethod := map[string]string{
+				"name":      "Waffo (Global Payment)",
+				"type":      model.PaymentMethodWaffo,
+				"color":     "rgba(var(--semi-blue-5), 1)",
+				"min_topup": strconv.Itoa(setting.WaffoMinTopUp),
+			}
+			payMethods = append(payMethods, waffoMethod)
+		}
+	}
+
+	enableWaffoPancake := isWaffoPancakeTopUpEnabled()
+	if enableWaffoPancake {
+		hasWaffoPancake := false
+		for _, method := range payMethods {
+			if method["type"] == model.PaymentMethodWaffoPancake {
+				hasWaffoPancake = true
+				break
+			}
+		}
+
+		if !hasWaffoPancake {
+			payMethods = append(payMethods, map[string]string{
+				"name":      "Waffo Pancake",
+				"type":      model.PaymentMethodWaffoPancake,
+				"color":     "rgba(var(--semi-orange-5), 1)",
+				"min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp),
+			})
+		}
+	}
+
 	data := gin.H{
-		"enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "",
-		"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
-		"enable_creem_topup":  setting.CreemApiKey != "" && setting.CreemProducts != "[]",
-		"creem_products":      setting.CreemProducts,
-		"pay_methods":         payMethods,
-		"min_topup":           operation_setting.MinTopUp,
-		"stripe_min_topup":    setting.StripeMinTopUp,
-		"amount_options":      operation_setting.GetPaymentSetting().AmountOptions,
-		"discount":            operation_setting.GetPaymentSetting().AmountDiscount,
+		"enable_online_topup":        isEpayTopUpEnabled(),
+		"enable_stripe_topup":        isStripeTopUpEnabled(),
+		"enable_creem_topup":         isCreemTopUpEnabled(),
+		"enable_waffo_topup":         enableWaffo,
+		"enable_waffo_pancake_topup": enableWaffoPancake,
+		"waffo_pay_methods": func() interface{} {
+			if enableWaffo {
+				return setting.GetWaffoPayMethods()
+			}
+			return nil
+		}(),
+		"creem_products":          setting.CreemProducts,
+		"pay_methods":             payMethods,
+		"min_topup":               operation_setting.MinTopUp,
+		"stripe_min_topup":        setting.StripeMinTopUp,
+		"waffo_min_topup":         setting.WaffoMinTopUp,
+		"waffo_pancake_min_topup": setting.WaffoPancakeMinTopUp,
+		"amount_options":          operation_setting.GetPaymentSetting().AmountOptions,
+		"discount":                operation_setting.GetPaymentSetting().AmountDiscount,
+		"topup_link":              common.TopUpLink,
 	}
 	common.ApiSuccess(c, data)
 }
@@ -129,28 +182,28 @@ func RequestEpay(c *gin.Context) {
 	var req EpayRequest
 	err := c.ShouldBindJSON(&req)
 	if err != nil {
-		c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
 		return
 	}
 	if req.Amount < getMinTopup() {
-		c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
 		return
 	}
 
 	id := c.GetInt("id")
 	group, err := model.GetUserGroup(id, true)
 	if err != nil {
-		c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
 		return
 	}
 	payMoney := getPayMoney(req.Amount, group)
 	if payMoney < 0.01 {
-		c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
 		return
 	}
 
 	if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
-		c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付方式不存在"})
 		return
 	}
 
@@ -161,7 +214,7 @@ func RequestEpay(c *gin.Context) {
 	tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
 	client := GetEpayClient()
 	if client == nil {
-		c.JSON(200, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
 		return
 	}
 	uri, params, err := client.Purchase(&epay.PurchaseArgs{
@@ -174,7 +227,8 @@ func RequestEpay(c *gin.Context) {
 		ReturnUrl:      returnUrl,
 	})
 	if err != nil {
-		c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
+		logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 拉起支付失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
 		return
 	}
 	amount := req.Amount
@@ -184,56 +238,80 @@ func RequestEpay(c *gin.Context) {
 		amount = dAmount.Div(dQuotaPerUnit).IntPart()
 	}
 	topUp := &model.TopUp{
-		UserId:        id,
-		Amount:        amount,
-		Money:         payMoney,
-		TradeNo:       tradeNo,
-		PaymentMethod: req.PaymentMethod,
-		CreateTime:    time.Now().Unix(),
-		Status:        "pending",
+		UserId:          id,
+		Amount:          amount,
+		Money:           payMoney,
+		TradeNo:         tradeNo,
+		PaymentMethod:   req.PaymentMethod,
+		PaymentProvider: model.PaymentProviderEpay,
+		CreateTime:      time.Now().Unix(),
+		Status:          common.TopUpStatusPending,
 	}
 	err = topUp.Insert()
 	if err != nil {
-		c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
+		logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 创建充值订单失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
 		return
 	}
-	c.JSON(200, gin.H{"message": "success", "data": params, "url": uri})
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值订单创建成功 user_id=%d trade_no=%s payment_method=%s amount=%d money=%.2f uri=%q params=%q", id, tradeNo, req.PaymentMethod, req.Amount, payMoney, uri, common.GetJsonString(params)))
+	c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
 }
 
 // tradeNo lock
 var orderLocks sync.Map
 var createLock sync.Mutex
 
+// refCountedMutex 带引用计数的互斥锁,确保最后一个使用者才从 map 中删除
+type refCountedMutex struct {
+	mu       sync.Mutex
+	refCount int
+}
+
 // LockOrder 尝试对给定订单号加锁
 func LockOrder(tradeNo string) {
-	lock, ok := orderLocks.Load(tradeNo)
-	if !ok {
-		createLock.Lock()
-		defer createLock.Unlock()
-		lock, ok = orderLocks.Load(tradeNo)
-		if !ok {
-			lock = new(sync.Mutex)
-			orderLocks.Store(tradeNo, lock)
-		}
+	createLock.Lock()
+	var rcm *refCountedMutex
+	if v, ok := orderLocks.Load(tradeNo); ok {
+		rcm = v.(*refCountedMutex)
+	} else {
+		rcm = &refCountedMutex{}
+		orderLocks.Store(tradeNo, rcm)
 	}
-	lock.(*sync.Mutex).Lock()
+	rcm.refCount++
+	createLock.Unlock()
+	rcm.mu.Lock()
 }
 
 // UnlockOrder 释放给定订单号的锁
 func UnlockOrder(tradeNo string) {
-	lock, ok := orderLocks.Load(tradeNo)
-	if ok {
-		lock.(*sync.Mutex).Unlock()
+	v, ok := orderLocks.Load(tradeNo)
+	if !ok {
+		return
+	}
+	rcm := v.(*refCountedMutex)
+	rcm.mu.Unlock()
+
+	createLock.Lock()
+	rcm.refCount--
+	if rcm.refCount == 0 {
+		orderLocks.Delete(tradeNo)
 	}
+	createLock.Unlock()
 }
 
 func EpayNotify(c *gin.Context) {
+	if !isEpayWebhookEnabled() {
+		logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
+		_, _ = c.Writer.Write([]byte("fail"))
+		return
+	}
+
 	var params map[string]string
 
 	if c.Request.Method == "POST" {
 		// POST 请求:从 POST body 解析参数
 		if err := c.Request.ParseForm(); err != nil {
-			log.Println("易支付回调POST解析失败:", err)
+			logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook POST 表单解析失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
 			_, _ = c.Writer.Write([]byte("fail"))
 			return
 		}
@@ -248,50 +326,63 @@ func EpayNotify(c *gin.Context) {
 			return r
 		}, map[string]string{})
 	}
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 收到请求 path=%q client_ip=%s method=%s params=%q", c.Request.RequestURI, c.ClientIP(), c.Request.Method, common.GetJsonString(params)))
 
 	if len(params) == 0 {
-		log.Println("易支付回调参数为空")
+		logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 参数为空 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
 		_, _ = c.Writer.Write([]byte("fail"))
 		return
 	}
 	client := GetEpayClient()
 	if client == nil {
-		log.Println("易支付回调失败 未找到配置信息")
+		logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 client 未初始化 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
 		_, err := c.Writer.Write([]byte("fail"))
 		if err != nil {
-			log.Println("易支付回调写入失败")
+			logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
 		}
 		return
 	}
 	verifyInfo, err := client.Verify(params)
 	if err == nil && verifyInfo.VerifyStatus {
+		logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
 		_, err := c.Writer.Write([]byte("success"))
 		if err != nil {
-			log.Println("易支付回调写入失败")
+			logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), err.Error()))
 		}
 	} else {
 		_, err := c.Writer.Write([]byte("fail"))
 		if err != nil {
-			log.Println("易支付回调写入失败")
+			logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
+		}
+		if err != nil {
+			logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
+		} else {
+			logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_status=false", c.Request.RequestURI, c.ClientIP()))
 		}
-		log.Println("易支付回调签名验证失败")
 		return
 	}
 
 	if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
-		log.Println(verifyInfo)
 		LockOrder(verifyInfo.ServiceTradeNo)
 		defer UnlockOrder(verifyInfo.ServiceTradeNo)
 		topUp := model.GetTopUpByTradeNo(verifyInfo.ServiceTradeNo)
 		if topUp == nil {
-			log.Printf("易支付回调未找到订单: %v", verifyInfo)
+			logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 回调订单不存在 trade_no=%s callback_type=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP(), common.GetJsonString(verifyInfo)))
+			return
+		}
+		if topUp.PaymentProvider != model.PaymentProviderEpay {
+			logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单支付网关不匹配 trade_no=%s order_provider=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentProvider, verifyInfo.Type, c.ClientIP()))
 			return
 		}
-		if topUp.Status == "pending" {
-			topUp.Status = "success"
+		if topUp.Status == common.TopUpStatusPending {
+			if topUp.PaymentMethod != verifyInfo.Type {
+				logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 实际支付方式与订单不同 trade_no=%s order_payment_method=%s actual_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentMethod, verifyInfo.Type, c.ClientIP()))
+				topUp.PaymentMethod = verifyInfo.Type
+			}
+			topUp.Status = common.TopUpStatusSuccess
 			err := topUp.Update()
 			if err != nil {
-				log.Printf("易支付回调更新订单失败: %v", topUp)
+				logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新充值订单失败 trade_no=%s user_id=%d client_ip=%s error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), err.Error(), common.GetJsonString(topUp)))
 				return
 			}
 			//user, _ := model.GetUserById(topUp.UserId, false)
@@ -301,14 +392,14 @@ func EpayNotify(c *gin.Context) {
 			quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart())
 			err = model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true)
 			if err != nil {
-				log.Printf("易支付回调更新用户失败: %v", topUp)
+				logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新用户额度失败 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, err.Error(), common.GetJsonString(topUp)))
 				return
 			}
-			log.Printf("易支付回调更新用户成功 %v", topUp)
-			model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money))
+			logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值成功 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d money=%.2f topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, topUp.Money, common.GetJsonString(topUp)))
+			model.RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), c.ClientIP(), topUp.PaymentMethod, "epay")
 		}
 	} else {
-		log.Printf("易支付异常回调: %v", verifyInfo)
+		logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 忽略事件 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
 	}
 }
 
@@ -316,26 +407,26 @@ func RequestAmount(c *gin.Context) {
 	var req AmountRequest
 	err := c.ShouldBindJSON(&req)
 	if err != nil {
-		c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
 		return
 	}
 
 	if req.Amount < getMinTopup() {
-		c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
 		return
 	}
 	id := c.GetInt("id")
 	group, err := model.GetUserGroup(id, true)
 	if err != nil {
-		c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
 		return
 	}
 	payMoney := getPayMoney(req.Amount, group)
 	if payMoney <= 0.01 {
-		c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
 		return
 	}
-	c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
+	c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
 }
 
 func GetUserTopUps(c *gin.Context) {
@@ -404,7 +495,7 @@ func AdminCompleteTopUp(c *gin.Context) {
 	LockOrder(req.TradeNo)
 	defer UnlockOrder(req.TradeNo)
 
-	if err := model.ManualCompleteTopUp(req.TradeNo); err != nil {
+	if err := model.ManualCompleteTopUp(req.TradeNo, c.ClientIP()); err != nil {
 		common.ApiError(c, err)
 		return
 	}

+ 64 - 71
controller/topup_creem.go

@@ -2,6 +2,7 @@ package controller
 
 import (
 	"bytes"
+	"context"
 	"crypto/hmac"
 	"crypto/sha256"
 	"encoding/hex"
@@ -9,10 +10,10 @@ import (
 	"errors"
 	"fmt"
 	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/logger"
 	"github.com/QuantumNous/new-api/model"
 	"github.com/QuantumNous/new-api/setting"
 	"io"
-	"log"
 	"net/http"
 	"time"
 
@@ -20,10 +21,7 @@ import (
 	"github.com/thanhpk/randstr"
 )
 
-const (
-	PaymentMethodCreem   = "creem"
-	CreemSignatureHeader = "creem-signature"
-)
+const CreemSignatureHeader = "creem-signature"
 
 var creemAdaptor = &CreemAdaptor{}
 
@@ -37,9 +35,9 @@ func generateCreemSignature(payload string, secret string) string {
 // 验证Creem webhook签名
 func verifyCreemSignature(payload string, signature string, secret string) bool {
 	if secret == "" {
-		log.Printf("Creem webhook secret not set")
+		logger.LogWarn(context.Background(), fmt.Sprintf("Creem webhook secret 未配置 test_mode=%t signature=%q body=%q", setting.CreemTestMode, signature, payload))
 		if setting.CreemTestMode {
-			log.Printf("Skip Creem webhook sign verify in test mode")
+			logger.LogInfo(context.Background(), fmt.Sprintf("Creem webhook 验签已跳过 reason=test_mode signature=%q body=%q", signature, payload))
 			return true
 		}
 		return false
@@ -66,13 +64,13 @@ type CreemAdaptor struct {
 }
 
 func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
-	if req.PaymentMethod != PaymentMethodCreem {
-		c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
+	if req.PaymentMethod != model.PaymentMethodCreem {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付渠道"})
 		return
 	}
 
 	if req.ProductId == "" {
-		c.JSON(200, gin.H{"message": "error", "data": "请选择产品"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "请选择产品"})
 		return
 	}
 
@@ -80,8 +78,8 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
 	var products []CreemProduct
 	err := json.Unmarshal([]byte(setting.CreemProducts), &products)
 	if err != nil {
-		log.Println("解析Creem产品列表失败", err)
-		c.JSON(200, gin.H{"message": "error", "data": "产品配置错误"})
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 产品配置解析失败 user_id=%d error=%q", c.GetInt("id"), err.Error()))
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品配置错误"})
 		return
 	}
 
@@ -95,7 +93,7 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
 	}
 
 	if selectedProduct == nil {
-		c.JSON(200, gin.H{"message": "error", "data": "产品不存在"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品不存在"})
 		return
 	}
 
@@ -108,32 +106,33 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
 
 	// 先创建订单记录,使用产品配置的金额和充值额度
 	topUp := &model.TopUp{
-		UserId:     id,
-		Amount:     selectedProduct.Quota, // 充值额度
-		Money:      selectedProduct.Price, // 支付金额
-		TradeNo:    referenceId,
-		CreateTime: time.Now().Unix(),
-		Status:     common.TopUpStatusPending,
+		UserId:          id,
+		Amount:          selectedProduct.Quota, // 充值额度
+		Money:           selectedProduct.Price, // 支付金额
+		TradeNo:         referenceId,
+		PaymentMethod:   model.PaymentMethodCreem,
+		PaymentProvider: model.PaymentProviderCreem,
+		CreateTime:      time.Now().Unix(),
+		Status:          common.TopUpStatusPending,
 	}
 	err = topUp.Insert()
 	if err != nil {
-		log.Printf("创建Creem订单失败: %v", err)
-		c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 创建充值订单失败 user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
 		return
 	}
 
 	// 创建支付链接,传入用户邮箱
-	checkoutUrl, err := genCreemLink(referenceId, selectedProduct, user.Email, user.Username)
+	checkoutUrl, err := genCreemLink(c.Request.Context(), referenceId, selectedProduct, user.Email, user.Username)
 	if err != nil {
-		log.Printf("获取Creem支付链接失败: %v", err)
-		c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 创建支付链接失败 user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
 		return
 	}
 
-	log.Printf("Creem订单创建成功 - 用户ID: %d, 订单号: %s, 产品: %s, 充值额度: %d, 支付金额: %.2f",
-		id, referenceId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price)
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值订单创建成功 user_id=%d trade_no=%s product_id=%s product_name=%q quota=%d money=%.2f", id, referenceId, selectedProduct.ProductId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price))
 
-	c.JSON(200, gin.H{
+	c.JSON(http.StatusOK, gin.H{
 		"message": "success",
 		"data": gin.H{
 			"checkout_url": checkoutUrl,
@@ -148,20 +147,19 @@ func RequestCreemPay(c *gin.Context) {
 	// 读取body内容用于打印,同时保留原始数据供后续使用
 	bodyBytes, err := io.ReadAll(c.Request.Body)
 	if err != nil {
-		log.Printf("read creem pay req body err: %v", err)
-		c.JSON(200, gin.H{"message": "error", "data": "read query error"})
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 支付请求读取失败 error=%q", err.Error()))
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "read query error"})
 		return
 	}
 
-	// 打印body内容
-	log.Printf("creem pay request body: %s", string(bodyBytes))
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 支付请求已收到 user_id=%d body=%q", c.GetInt("id"), string(bodyBytes)))
 
 	// 重新设置body供后续的ShouldBindJSON使用
 	c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
 
 	err = c.ShouldBindJSON(&req)
 	if err != nil {
-		c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
 		return
 	}
 	creemAdaptor.RequestPay(c, &req)
@@ -229,35 +227,37 @@ type CreemWebhookEvent struct {
 }
 
 func CreemWebhook(c *gin.Context) {
+	if !isCreemWebhookEnabled() {
+		logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
+		c.AbortWithStatus(http.StatusForbidden)
+		return
+	}
+
 	// 读取body内容用于打印,同时保留原始数据供后续使用
 	bodyBytes, err := io.ReadAll(c.Request.Body)
 	if err != nil {
-		log.Printf("读取Creem Webhook请求body失败: %v", err)
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Creem webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
 		c.AbortWithStatus(http.StatusBadRequest)
 		return
 	}
 
 	// 获取签名头
 	signature := c.GetHeader(CreemSignatureHeader)
-
-	// 打印关键信息(避免输出完整敏感payload)
-	log.Printf("Creem Webhook - URI: %s", c.Request.RequestURI)
-	if setting.CreemTestMode {
-		log.Printf("Creem Webhook - Signature: %s , Body: %s", signature, bodyBytes)
-	} else if signature == "" {
-		log.Printf("Creem Webhook缺少签名头")
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
+	if signature == "" {
+		logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 缺少签名 path=%q client_ip=%s body=%q", c.Request.RequestURI, c.ClientIP(), string(bodyBytes)))
 		c.AbortWithStatus(http.StatusUnauthorized)
 		return
 	}
 
 	// 验证签名
 	if !verifyCreemSignature(string(bodyBytes), signature, setting.CreemWebhookSecret) {
-		log.Printf("Creem Webhook签名验证失败")
+		logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 验签失败 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
 		c.AbortWithStatus(http.StatusUnauthorized)
 		return
 	}
 
-	log.Printf("Creem Webhook签名验证成功")
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 验签成功 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
 
 	// 重新设置body供后续的ShouldBindJSON使用
 	c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
@@ -265,19 +265,19 @@ func CreemWebhook(c *gin.Context) {
 	// 解析新格式的webhook数据
 	var webhookEvent CreemWebhookEvent
 	if err := c.ShouldBindJSON(&webhookEvent); err != nil {
-		log.Printf("解析Creem Webhook参数失败: %v", err)
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Creem webhook 解析失败 path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), string(bodyBytes)))
 		c.AbortWithStatus(http.StatusBadRequest)
 		return
 	}
 
-	log.Printf("Creem Webhook解析成功 - EventType: %s, EventId: %s", webhookEvent.EventType, webhookEvent.Id)
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 解析成功 event_type=%s event_id=%s request_id=%s order_id=%s order_status=%s", webhookEvent.EventType, webhookEvent.Id, webhookEvent.Object.RequestId, webhookEvent.Object.Order.Id, webhookEvent.Object.Order.Status))
 
 	// 根据事件类型处理不同的webhook
 	switch webhookEvent.EventType {
 	case "checkout.completed":
 		handleCheckoutCompleted(c, &webhookEvent)
 	default:
-		log.Printf("忽略Creem Webhook事件类型: %s", webhookEvent.EventType)
+		logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 忽略事件 event_type=%s event_id=%s", webhookEvent.EventType, webhookEvent.Id))
 		c.Status(http.StatusOK)
 	}
 }
@@ -286,7 +286,7 @@ func CreemWebhook(c *gin.Context) {
 func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
 	// 验证订单状态
 	if event.Object.Order.Status != "paid" {
-		log.Printf("订单状态不是已支付: %s, 跳过处理", event.Object.Order.Status)
+		logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 订单状态未支付,忽略处理 request_id=%s order_id=%s order_status=%s", event.Object.RequestId, event.Object.Order.Id, event.Object.Order.Status))
 		c.Status(http.StatusOK)
 		return
 	}
@@ -294,7 +294,7 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
 	// 获取引用ID(这是我们创建订单时传递的request_id)
 	referenceId := event.Object.RequestId
 	if referenceId == "" {
-		log.Println("Creem Webhook缺少request_id字段")
+		logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 缺少 request_id event_id=%s order_id=%s", event.Id, event.Object.Order.Id))
 		c.AbortWithStatus(http.StatusBadRequest)
 		return
 	}
@@ -302,40 +302,35 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
 	// Try complete subscription order first
 	LockOrder(referenceId)
 	defer UnlockOrder(referenceId)
-	if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(event)); err == nil {
+	if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(event), model.PaymentProviderCreem, ""); err == nil {
+		logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 订阅订单处理成功 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
 		c.Status(http.StatusOK)
 		return
 	} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
-		log.Printf("Creem订阅订单处理失败: %s, 订单号: %s", err.Error(), referenceId)
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅订单处理失败 trade_no=%s creem_order_id=%s error=%q", referenceId, event.Object.Order.Id, err.Error()))
 		c.AbortWithStatus(http.StatusInternalServerError)
 		return
 	}
 
 	// 验证订单类型,目前只处理一次性付款(充值)
 	if event.Object.Order.Type != "onetime" {
-		log.Printf("暂不支持的订单类型: %s, 跳过处理", event.Object.Order.Type)
+		logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 暂不支持该订单类型,忽略处理 request_id=%s creem_order_id=%s order_type=%s", referenceId, event.Object.Order.Id, event.Object.Order.Type))
 		c.Status(http.StatusOK)
 		return
 	}
 
-	// 记录详细的支付信息
-	log.Printf("处理Creem支付完成 - 订单号: %s, Creem订单ID: %s, 支付金额: %d %s, 客户邮箱: <redacted>, 产品: %s",
-		referenceId,
-		event.Object.Order.Id,
-		event.Object.Order.AmountPaid,
-		event.Object.Order.Currency,
-		event.Object.Product.Name)
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 支付完成回调 trade_no=%s creem_order_id=%s amount_paid=%d currency=%s product_name=%q customer_email=%q customer_name=%q", referenceId, event.Object.Order.Id, event.Object.Order.AmountPaid, event.Object.Order.Currency, event.Object.Product.Name, event.Object.Customer.Email, event.Object.Customer.Name))
 
 	// 查询本地订单确认存在
 	topUp := model.GetTopUpByTradeNo(referenceId)
 	if topUp == nil {
-		log.Printf("Creem充值订单不存在: %s", referenceId)
+		logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 充值订单不存在 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
 		c.AbortWithStatus(http.StatusBadRequest)
 		return
 	}
 
 	if topUp.Status != common.TopUpStatusPending {
-		log.Printf("Creem充值订单状态错误: %s, 当前状态: %s", referenceId, topUp.Status)
+		logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值订单状态非 pending,忽略处理 trade_no=%s status=%s creem_order_id=%s", referenceId, topUp.Status, event.Object.Order.Id))
 		c.Status(http.StatusOK) // 已处理过的订单,返回成功避免重复处理
 		return
 	}
@@ -346,21 +341,20 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
 
 	// 防护性检查,确保邮箱和姓名不为空字符串
 	if customerEmail == "" {
-		log.Printf("警告:Creem回调中客户邮箱为空 - 订单号: %s", referenceId)
+		logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 回调客户邮箱为空 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
 	}
 	if customerName == "" {
-		log.Printf("警告:Creem回调中客户姓名为空 - 订单号: %s", referenceId)
+		logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 回调客户姓名为空 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
 	}
 
-	err := model.RechargeCreem(referenceId, customerEmail, customerName)
+	err := model.RechargeCreem(referenceId, customerEmail, customerName, c.ClientIP())
 	if err != nil {
-		log.Printf("Creem充值处理失败: %s, 订单号: %s", err.Error(), referenceId)
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 充值处理失败 trade_no=%s creem_order_id=%s client_ip=%s error=%q", referenceId, event.Object.Order.Id, c.ClientIP(), err.Error()))
 		c.AbortWithStatus(http.StatusInternalServerError)
 		return
 	}
 
-	log.Printf("Creem充值成功 - 订单号: %s, 充值额度: %d, 支付金额: %.2f",
-		referenceId, topUp.Amount, topUp.Money)
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值成功 trade_no=%s creem_order_id=%s quota=%d money=%.2f client_ip=%s", referenceId, event.Object.Order.Id, topUp.Amount, topUp.Money, c.ClientIP()))
 	c.Status(http.StatusOK)
 }
 
@@ -378,7 +372,7 @@ type CreemCheckoutResponse struct {
 	Id          string `json:"id"`
 }
 
-func genCreemLink(referenceId string, product *CreemProduct, email string, username string) (string, error) {
+func genCreemLink(ctx context.Context, referenceId string, product *CreemProduct, email string, username string) (string, error) {
 	if setting.CreemApiKey == "" {
 		return "", fmt.Errorf("未配置Creem API密钥")
 	}
@@ -387,7 +381,7 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
 	apiUrl := "https://api.creem.io/v1/checkouts"
 	if setting.CreemTestMode {
 		apiUrl = "https://test-api.creem.io/v1/checkouts"
-		log.Printf("使用Creem测试环境: %s", apiUrl)
+		logger.LogInfo(ctx, fmt.Sprintf("Creem 使用测试环境 api_url=%s", apiUrl))
 	}
 
 	// 构建请求数据,确保包含用户邮箱
@@ -423,8 +417,7 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
 	req.Header.Set("Content-Type", "application/json")
 	req.Header.Set("x-api-key", setting.CreemApiKey)
 
-	log.Printf("发送Creem支付请求 - URL: %s, 产品ID: %s, 用户邮箱: %s, 订单号: %s",
-		apiUrl, product.ProductId, email, referenceId)
+	logger.LogInfo(ctx, fmt.Sprintf("Creem 支付请求已发送 api_url=%s product_id=%s email=%q trade_no=%s", apiUrl, product.ProductId, email, referenceId))
 
 	// 发送请求
 	client := &http.Client{
@@ -442,7 +435,7 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
 		return "", fmt.Errorf("读取响应失败: %v", err)
 	}
 
-	log.Printf("Creem API resp - status code: %d, resp: %s", resp.StatusCode, string(body))
+	logger.LogInfo(ctx, fmt.Sprintf("Creem API 响应已收到 trade_no=%s status_code=%d body=%q", referenceId, resp.StatusCode, string(body)))
 
 	// 检查响应状态
 	if resp.StatusCode/100 != 2 {
@@ -459,6 +452,6 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
 		return "", fmt.Errorf("Creem API resp no checkout url ")
 	}
 
-	log.Printf("Creem 支付链接创建成功 - 订单号: %s, 支付链接: %s", referenceId, checkoutResp.CheckoutUrl)
+	logger.LogInfo(ctx, fmt.Sprintf("Creem 支付链接创建成功 trade_no=%s response_id=%s checkout_url=%q", referenceId, checkoutResp.Id, checkoutResp.CheckoutUrl))
 	return checkoutResp.CheckoutUrl, nil
 }

+ 129 - 58
controller/topup_stripe.go

@@ -1,16 +1,17 @@
 package controller
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"io"
-	"log"
 	"net/http"
 	"strconv"
 	"strings"
 	"time"
 
 	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/logger"
 	"github.com/QuantumNous/new-api/model"
 	"github.com/QuantumNous/new-api/setting"
 	"github.com/QuantumNous/new-api/setting/operation_setting"
@@ -23,10 +24,6 @@ import (
 	"github.com/thanhpk/randstr"
 )
 
-const (
-	PaymentMethodStripe = "stripe"
-)
-
 var stripeAdaptor = &StripeAdaptor{}
 
 // StripePayRequest represents a payment request for Stripe checkout.
@@ -48,34 +45,34 @@ type StripeAdaptor struct {
 
 func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) {
 	if req.Amount < getStripeMinTopup() {
-		c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())})
 		return
 	}
 	id := c.GetInt("id")
 	group, err := model.GetUserGroup(id, true)
 	if err != nil {
-		c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
 		return
 	}
 	payMoney := getStripePayMoney(float64(req.Amount), group)
 	if payMoney <= 0.01 {
-		c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
 		return
 	}
-	c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
+	c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
 }
 
 func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
-	if req.PaymentMethod != PaymentMethodStripe {
-		c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
+	if req.PaymentMethod != model.PaymentMethodStripe {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付渠道"})
 		return
 	}
 	if req.Amount < getStripeMinTopup() {
-		c.JSON(200, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "data": 10})
+		c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "data": 10})
 		return
 	}
 	if req.Amount > 10000 {
-		c.JSON(200, gin.H{"message": "充值数量不能大于 10000", "data": 10})
+		c.JSON(http.StatusOK, gin.H{"message": "充值数量不能大于 10000", "data": 10})
 		return
 	}
 
@@ -98,26 +95,29 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
 
 	payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount, req.SuccessURL, req.CancelURL)
 	if err != nil {
-		log.Println("获取Stripe Checkout支付链接失败", err)
-		c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 创建 Checkout Session 失败 user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
 		return
 	}
 
 	topUp := &model.TopUp{
-		UserId:        id,
-		Amount:        req.Amount,
-		Money:         chargedMoney,
-		TradeNo:       referenceId,
-		PaymentMethod: PaymentMethodStripe,
-		CreateTime:    time.Now().Unix(),
-		Status:        common.TopUpStatusPending,
+		UserId:          id,
+		Amount:          req.Amount,
+		Money:           chargedMoney,
+		TradeNo:         referenceId,
+		PaymentMethod:   model.PaymentMethodStripe,
+		PaymentProvider: model.PaymentProviderStripe,
+		CreateTime:      time.Now().Unix(),
+		Status:          common.TopUpStatusPending,
 	}
 	err = topUp.Insert()
 	if err != nil {
-		c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
 		return
 	}
-	c.JSON(200, gin.H{
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("Stripe 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f", id, referenceId, req.Amount, chargedMoney))
+	c.JSON(http.StatusOK, gin.H{
 		"message": "success",
 		"data": gin.H{
 			"pay_link": payLink,
@@ -129,7 +129,7 @@ func RequestStripeAmount(c *gin.Context) {
 	var req StripePayRequest
 	err := c.ShouldBindJSON(&req)
 	if err != nil {
-		c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
 		return
 	}
 	stripeAdaptor.RequestAmount(c, &req)
@@ -139,54 +139,130 @@ func RequestStripePay(c *gin.Context) {
 	var req StripePayRequest
 	err := c.ShouldBindJSON(&req)
 	if err != nil {
-		c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
 		return
 	}
 	stripeAdaptor.RequestPay(c, &req)
 }
 
 func StripeWebhook(c *gin.Context) {
+	ctx := c.Request.Context()
+	if !isStripeWebhookEnabled() {
+		logger.LogWarn(ctx, fmt.Sprintf("Stripe webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
+		c.AbortWithStatus(http.StatusForbidden)
+		return
+	}
+
 	payload, err := io.ReadAll(c.Request.Body)
 	if err != nil {
-		log.Printf("解析Stripe Webhook参数失败: %v\n", err)
+		logger.LogError(ctx, fmt.Sprintf("Stripe webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
 		c.AbortWithStatus(http.StatusServiceUnavailable)
 		return
 	}
 
 	signature := c.GetHeader("Stripe-Signature")
-	endpointSecret := setting.StripeWebhookSecret
-	event, err := webhook.ConstructEventWithOptions(payload, signature, endpointSecret, webhook.ConstructEventOptions{
+	logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(payload)))
+	event, err := webhook.ConstructEventWithOptions(payload, signature, setting.StripeWebhookSecret, webhook.ConstructEventOptions{
 		IgnoreAPIVersionMismatch: true,
 	})
 
 	if err != nil {
-		log.Printf("Stripe Webhook验签失败: %v\n", err)
+		logger.LogWarn(ctx, fmt.Sprintf("Stripe webhook 验签失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
 		c.AbortWithStatus(http.StatusBadRequest)
 		return
 	}
 
+	callerIp := c.ClientIP()
+	logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 验签成功 event_type=%s client_ip=%s path=%q", string(event.Type), callerIp, c.Request.RequestURI))
 	switch event.Type {
 	case stripe.EventTypeCheckoutSessionCompleted:
-		sessionCompleted(event)
+		sessionCompleted(ctx, event, callerIp)
 	case stripe.EventTypeCheckoutSessionExpired:
-		sessionExpired(event)
+		sessionExpired(ctx, event)
+	case stripe.EventTypeCheckoutSessionAsyncPaymentSucceeded:
+		sessionAsyncPaymentSucceeded(ctx, event, callerIp)
+	case stripe.EventTypeCheckoutSessionAsyncPaymentFailed:
+		sessionAsyncPaymentFailed(ctx, event, callerIp)
 	default:
-		log.Printf("不支持的Stripe Webhook事件类型: %s\n", event.Type)
+		logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 忽略事件 event_type=%s client_ip=%s", string(event.Type), callerIp))
 	}
 
 	c.Status(http.StatusOK)
 }
 
-func sessionCompleted(event stripe.Event) {
+func sessionCompleted(ctx context.Context, event stripe.Event, callerIp string) {
 	customerId := event.GetObjectValue("customer")
 	referenceId := event.GetObjectValue("client_reference_id")
 	status := event.GetObjectValue("status")
 	if "complete" != status {
-		log.Println("错误的Stripe Checkout完成状态:", status, ",", referenceId)
+		logger.LogWarn(ctx, fmt.Sprintf("Stripe checkout.completed 状态异常,忽略处理 trade_no=%s status=%s client_ip=%s", referenceId, status, callerIp))
+		return
+	}
+
+	paymentStatus := event.GetObjectValue("payment_status")
+	if paymentStatus != "paid" {
+		logger.LogInfo(ctx, fmt.Sprintf("Stripe Checkout 支付未完成,等待异步结果 trade_no=%s payment_status=%s client_ip=%s", referenceId, paymentStatus, callerIp))
+		return
+	}
+
+	fulfillOrder(ctx, event, referenceId, customerId, callerIp)
+}
+
+// sessionAsyncPaymentSucceeded handles delayed payment methods (bank transfer, SEPA, etc.)
+// that confirm payment after the checkout session completes.
+func sessionAsyncPaymentSucceeded(ctx context.Context, event stripe.Event, callerIp string) {
+	customerId := event.GetObjectValue("customer")
+	referenceId := event.GetObjectValue("client_reference_id")
+	logger.LogInfo(ctx, fmt.Sprintf("Stripe 异步支付成功 trade_no=%s client_ip=%s", referenceId, callerIp))
+
+	fulfillOrder(ctx, event, referenceId, customerId, callerIp)
+}
+
+// sessionAsyncPaymentFailed marks orders as failed when delayed payment methods
+// ultimately fail (e.g. bank transfer not received, SEPA rejected).
+func sessionAsyncPaymentFailed(ctx context.Context, event stripe.Event, callerIp string) {
+	referenceId := event.GetObjectValue("client_reference_id")
+	logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败 trade_no=%s client_ip=%s", referenceId, callerIp))
+
+	if len(referenceId) == 0 {
+		logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败事件缺少订单号 client_ip=%s", callerIp))
+		return
+	}
+
+	LockOrder(referenceId)
+	defer UnlockOrder(referenceId)
+
+	topUp := model.GetTopUpByTradeNo(referenceId)
+	if topUp == nil {
+		logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败但本地订单不存在 trade_no=%s client_ip=%s", referenceId, callerIp))
+		return
+	}
+
+	if topUp.PaymentProvider != model.PaymentProviderStripe {
+		logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败但订单支付网关不匹配 trade_no=%s payment_provider=%s client_ip=%s", referenceId, topUp.PaymentProvider, callerIp))
+		return
+	}
+
+	if topUp.Status != common.TopUpStatusPending {
+		logger.LogInfo(ctx, fmt.Sprintf("Stripe 异步支付失败但订单状态非 pending,忽略处理 trade_no=%s status=%s client_ip=%s", referenceId, topUp.Status, callerIp))
+		return
+	}
+
+	topUp.Status = common.TopUpStatusFailed
+	if err := topUp.Update(); err != nil {
+		logger.LogError(ctx, fmt.Sprintf("Stripe 标记充值订单失败状态失败 trade_no=%s client_ip=%s error=%q", referenceId, callerIp, err.Error()))
+		return
+	}
+	logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值订单已标记为失败 trade_no=%s client_ip=%s", referenceId, callerIp))
+}
+
+// fulfillOrder is the shared logic for crediting quota after payment is confirmed.
+func fulfillOrder(ctx context.Context, event stripe.Event, referenceId string, customerId string, callerIp string) {
+	if len(referenceId) == 0 {
+		logger.LogWarn(ctx, fmt.Sprintf("Stripe 完成订单时缺少订单号 client_ip=%s", callerIp))
 		return
 	}
 
-	// Try complete subscription order first
 	LockOrder(referenceId)
 	defer UnlockOrder(referenceId)
 	payload := map[string]any{
@@ -195,65 +271,60 @@ func sessionCompleted(event stripe.Event) {
 		"currency":     strings.ToUpper(event.GetObjectValue("currency")),
 		"event_type":   string(event.Type),
 	}
-	if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload)); err == nil {
+	if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload), model.PaymentProviderStripe, ""); err == nil {
+		logger.LogInfo(ctx, fmt.Sprintf("Stripe 订阅订单处理成功 trade_no=%s event_type=%s client_ip=%s", referenceId, string(event.Type), callerIp))
 		return
 	} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
-		log.Println("complete subscription order failed:", err.Error(), referenceId)
+		logger.LogError(ctx, fmt.Sprintf("Stripe 订阅订单处理失败 trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
 		return
 	}
 
-	err := model.Recharge(referenceId, customerId)
+	err := model.Recharge(referenceId, customerId, callerIp)
 	if err != nil {
-		log.Println(err.Error(), referenceId)
+		logger.LogError(ctx, fmt.Sprintf("Stripe 充值处理失败 trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
 		return
 	}
 
 	total, _ := strconv.ParseFloat(event.GetObjectValue("amount_total"), 64)
 	currency := strings.ToUpper(event.GetObjectValue("currency"))
-	log.Printf("收到款项:%s, %.2f(%s)", referenceId, total/100, currency)
+	logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值成功 trade_no=%s amount_total=%.2f currency=%s event_type=%s client_ip=%s", referenceId, total/100, currency, string(event.Type), callerIp))
 }
 
-func sessionExpired(event stripe.Event) {
+func sessionExpired(ctx context.Context, event stripe.Event) {
 	referenceId := event.GetObjectValue("client_reference_id")
 	status := event.GetObjectValue("status")
 	if "expired" != status {
-		log.Println("错误的Stripe Checkout过期状态:", status, ",", referenceId)
+		logger.LogWarn(ctx, fmt.Sprintf("Stripe checkout.expired 状态异常,忽略处理 trade_no=%s status=%s", referenceId, status))
 		return
 	}
 
 	if len(referenceId) == 0 {
-		log.Println("未提供支付单号")
+		logger.LogWarn(ctx, "Stripe checkout.expired 缺少订单号")
 		return
 	}
 
 	// Subscription order expiration
 	LockOrder(referenceId)
 	defer UnlockOrder(referenceId)
-	if err := model.ExpireSubscriptionOrder(referenceId); err == nil {
+	if err := model.ExpireSubscriptionOrder(referenceId, model.PaymentProviderStripe); err == nil {
+		logger.LogInfo(ctx, fmt.Sprintf("Stripe 订阅订单已过期 trade_no=%s", referenceId))
 		return
 	} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
-		log.Println("过期订阅订单失败", referenceId, ", err:", err.Error())
+		logger.LogError(ctx, fmt.Sprintf("Stripe 订阅订单过期处理失败 trade_no=%s error=%q", referenceId, err.Error()))
 		return
 	}
 
-	topUp := model.GetTopUpByTradeNo(referenceId)
-	if topUp == nil {
-		log.Println("充值订单不存在", referenceId)
+	err := model.UpdatePendingTopUpStatus(referenceId, model.PaymentProviderStripe, common.TopUpStatusExpired)
+	if errors.Is(err, model.ErrTopUpNotFound) {
+		logger.LogWarn(ctx, fmt.Sprintf("Stripe 充值订单不存在,无法标记过期 trade_no=%s", referenceId))
 		return
 	}
-
-	if topUp.Status != common.TopUpStatusPending {
-		log.Println("充值订单状态错误", referenceId)
-	}
-
-	topUp.Status = common.TopUpStatusExpired
-	err := topUp.Update()
 	if err != nil {
-		log.Println("过期充值订单失败", referenceId, ", err:", err.Error())
+		logger.LogError(ctx, fmt.Sprintf("Stripe 充值订单过期处理失败 trade_no=%s error=%q", referenceId, err.Error()))
 		return
 	}
 
-	log.Println("充值订单已过期", referenceId)
+	logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值订单已过期 trade_no=%s", referenceId))
 }
 
 // genStripeLink generates a Stripe Checkout session URL for payment.

+ 418 - 0
controller/topup_waffo.go

@@ -0,0 +1,418 @@
+package controller
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strconv"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/logger"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/service"
+	"github.com/QuantumNous/new-api/setting"
+	"github.com/QuantumNous/new-api/setting/operation_setting"
+	"github.com/QuantumNous/new-api/setting/system_setting"
+	"github.com/gin-gonic/gin"
+	"github.com/thanhpk/randstr"
+	waffo "github.com/waffo-com/waffo-go"
+	"github.com/waffo-com/waffo-go/config"
+	"github.com/waffo-com/waffo-go/core"
+	"github.com/waffo-com/waffo-go/types/order"
+)
+
+func getWaffoSDK() (*waffo.Waffo, error) {
+	env := config.Sandbox
+	apiKey := setting.WaffoSandboxApiKey
+	privateKey := setting.WaffoSandboxPrivateKey
+	publicKey := setting.WaffoSandboxPublicCert
+	if !setting.WaffoSandbox {
+		env = config.Production
+		apiKey = setting.WaffoApiKey
+		privateKey = setting.WaffoPrivateKey
+		publicKey = setting.WaffoPublicCert
+	}
+	builder := config.NewConfigBuilder().
+		APIKey(apiKey).
+		PrivateKey(privateKey).
+		WaffoPublicKey(publicKey).
+		Environment(env)
+	if setting.WaffoMerchantId != "" {
+		builder = builder.MerchantID(setting.WaffoMerchantId)
+	}
+	cfg, err := builder.Build()
+	if err != nil {
+		return nil, err
+	}
+	return waffo.New(cfg), nil
+}
+
+func getWaffoUserEmail(user *model.User) string {
+	return fmt.Sprintf("%d@examples.com", user.Id)
+}
+
+func getWaffoCurrency() string {
+	if setting.WaffoCurrency != "" {
+		return setting.WaffoCurrency
+	}
+	return "USD"
+}
+
+// zeroDecimalCurrencies 零小数位币种,金额不能带小数点
+var zeroDecimalCurrencies = map[string]bool{
+	"IDR": true, "JPY": true, "KRW": true, "VND": true,
+}
+
+func formatWaffoAmount(amount float64, currency string) string {
+	if zeroDecimalCurrencies[currency] {
+		return fmt.Sprintf("%.0f", amount)
+	}
+	return fmt.Sprintf("%.2f", amount)
+}
+
+// getWaffoPayMoney converts the user-facing amount to USD for Waffo payment.
+// Waffo only accepts USD, so this function handles the conversion from different
+// display types (USD/CNY/TOKENS) to the actual USD amount to charge.
+func getWaffoPayMoney(amount float64, group string) float64 {
+	originalAmount := amount
+	if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
+		amount = amount / common.QuotaPerUnit
+	}
+	topupGroupRatio := common.GetTopupGroupRatio(group)
+	if topupGroupRatio == 0 {
+		topupGroupRatio = 1
+	}
+	discount := 1.0
+	if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok {
+		if ds > 0 {
+			discount = ds
+		}
+	}
+	return amount * setting.WaffoUnitPrice * topupGroupRatio * discount
+}
+
+type WaffoPayRequest struct {
+	Amount         int64  `json:"amount"`
+	PayMethodIndex *int   `json:"pay_method_index"` // 服务端支付方式列表的索引,nil 表示由 Waffo 自动选择
+	PayMethodType  string `json:"pay_method_type"`  // Deprecated: 兼容旧前端,优先使用 pay_method_index
+	PayMethodName  string `json:"pay_method_name"`  // Deprecated: 兼容旧前端,优先使用 pay_method_index
+}
+
+func RequestWaffoAmount(c *gin.Context) {
+	var req WaffoPayRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
+		return
+	}
+
+	waffoMinTopup := int64(setting.WaffoMinTopUp)
+	if req.Amount < waffoMinTopup {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
+		return
+	}
+
+	id := c.GetInt("id")
+	group, err := model.GetUserGroup(id, true)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
+		return
+	}
+
+	payMoney := getWaffoPayMoney(float64(req.Amount), group)
+	if payMoney <= 0.01 {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
+}
+
+// RequestWaffoPay 创建 Waffo 支付订单
+func RequestWaffoPay(c *gin.Context) {
+	if !setting.WaffoEnabled {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo 支付未启用"})
+		return
+	}
+
+	var req WaffoPayRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
+		return
+	}
+	waffoMinTopup := int64(setting.WaffoMinTopUp)
+	if req.Amount < waffoMinTopup {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
+		return
+	}
+
+	id := c.GetInt("id")
+	user, err := model.GetUserById(id, false)
+	if err != nil || user == nil {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
+		return
+	}
+
+	// 从服务端配置查找支付方式,客户端只传索引或旧字段
+	var resolvedPayMethodType, resolvedPayMethodName string
+	methods := setting.GetWaffoPayMethods()
+	if req.PayMethodIndex != nil {
+		// 新协议:按索引查找
+		idx := *req.PayMethodIndex
+		if idx < 0 || idx >= len(methods) {
+			logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 支付方式索引无效 user_id=%d pay_method_index=%d method_count=%d", id, idx, len(methods)))
+			c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"})
+			return
+		}
+		resolvedPayMethodType = methods[idx].PayMethodType
+		resolvedPayMethodName = methods[idx].PayMethodName
+	} else if req.PayMethodType != "" {
+		// 兼容旧前端:验证客户端传的值在服务端列表中
+		valid := false
+		for _, m := range methods {
+			if m.PayMethodType == req.PayMethodType && m.PayMethodName == req.PayMethodName {
+				valid = true
+				resolvedPayMethodType = m.PayMethodType
+				resolvedPayMethodName = m.PayMethodName
+				break
+			}
+		}
+		if !valid {
+			logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 支付方式无效 user_id=%d pay_method_type=%s pay_method_name=%q", id, req.PayMethodType, req.PayMethodName))
+			c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"})
+			return
+		}
+	}
+	// resolvedPayMethodType/Name 为空时,Waffo 自动选择支付方式
+
+	group, _ := model.GetUserGroup(id, true)
+	payMoney := getWaffoPayMoney(float64(req.Amount), group)
+	if payMoney < 0.01 {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
+		return
+	}
+
+	// 生成唯一订单号,paymentRequestId 与 merchantOrderId 保持一致,简化追踪
+	merchantOrderId := fmt.Sprintf("WAFFO-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(6))
+	paymentRequestId := merchantOrderId
+
+	// Token 模式下归一化 Amount(存等价美元/CNY 数量,避免 RechargeWaffo 双重放大)
+	amount := req.Amount
+	if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
+		amount = int64(float64(req.Amount) / common.QuotaPerUnit)
+		if amount < 1 {
+			amount = 1
+		}
+	}
+
+	// 创建本地订单
+	topUp := &model.TopUp{
+		UserId:          id,
+		Amount:          amount,
+		Money:           payMoney,
+		TradeNo:         merchantOrderId,
+		PaymentMethod:   model.PaymentMethodWaffo,
+		PaymentProvider: model.PaymentProviderWaffo,
+		CreateTime:      time.Now().Unix(),
+		Status:          common.TopUpStatusPending,
+	}
+	if err := topUp.Insert(); err != nil {
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, merchantOrderId, req.Amount, err.Error()))
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
+		return
+	}
+
+	sdk, err := getWaffoSDK()
+	if err != nil {
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo SDK 初始化失败 user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
+		topUp.Status = common.TopUpStatusFailed
+		_ = topUp.Update()
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付配置错误"})
+		return
+	}
+
+	callbackAddr := service.GetCallbackAddress()
+	notifyUrl := callbackAddr + "/api/waffo/webhook"
+	if setting.WaffoNotifyUrl != "" {
+		notifyUrl = setting.WaffoNotifyUrl
+	}
+	returnUrl := system_setting.ServerAddress + "/console/topup?show_history=true"
+	if setting.WaffoReturnUrl != "" {
+		returnUrl = setting.WaffoReturnUrl
+	}
+
+	currency := getWaffoCurrency()
+	createParams := &order.CreateOrderParams{
+		PaymentRequestID: paymentRequestId,
+		MerchantOrderID:  merchantOrderId,
+		OrderAmount:      formatWaffoAmount(payMoney, currency),
+		OrderCurrency:    currency,
+		OrderDescription: fmt.Sprintf("Recharge %d credits", req.Amount),
+		OrderRequestedAt: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
+		NotifyURL:        notifyUrl,
+		MerchantInfo: &order.MerchantInfo{
+			MerchantID: setting.WaffoMerchantId,
+		},
+		UserInfo: &order.UserInfo{
+			UserID:       strconv.Itoa(user.Id),
+			UserEmail:    getWaffoUserEmail(user),
+			UserTerminal: "WEB",
+		},
+		PaymentInfo: &order.PaymentInfo{
+			ProductName:   "ONE_TIME_PAYMENT",
+			PayMethodType: resolvedPayMethodType,
+			PayMethodName: resolvedPayMethodName,
+		},
+		SuccessRedirectURL: returnUrl,
+		FailedRedirectURL:  returnUrl,
+	}
+	resp, err := sdk.Order().Create(c.Request.Context(), createParams, nil)
+	if err != nil {
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 创建订单失败 user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
+		topUp.Status = common.TopUpStatusFailed
+		_ = topUp.Update()
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
+		return
+	}
+	if !resp.IsSuccess() {
+		logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 创建订单业务失败 user_id=%d trade_no=%s code=%s message=%q response=%q", id, merchantOrderId, resp.Code, resp.Message, common.GetJsonString(resp)))
+		topUp.Status = common.TopUpStatusFailed
+		_ = topUp.Update()
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
+		return
+	}
+
+	orderData := resp.GetData()
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f pay_method_type=%s pay_method_name=%q", id, merchantOrderId, req.Amount, payMoney, resolvedPayMethodType, resolvedPayMethodName))
+
+	paymentUrl := orderData.FetchRedirectURL()
+	if paymentUrl == "" {
+		paymentUrl = orderData.OrderAction
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "success",
+		"data": gin.H{
+			"payment_url": paymentUrl,
+			"order_id":    merchantOrderId,
+		},
+	})
+}
+
+// webhookPayloadWithSubInfo 扩展 PAYMENT_NOTIFICATION,包含 SDK 未定义的 subscriptionInfo 字段
+type webhookPayloadWithSubInfo struct {
+	EventType string `json:"eventType"`
+	Result    struct {
+		core.PaymentNotificationResult
+		SubscriptionInfo *webhookSubscriptionInfo `json:"subscriptionInfo,omitempty"`
+	} `json:"result"`
+}
+
+type webhookSubscriptionInfo struct {
+	Period              string `json:"period,omitempty"`
+	MerchantRequest     string `json:"merchantRequest,omitempty"`
+	SubscriptionID      string `json:"subscriptionId,omitempty"`
+	SubscriptionRequest string `json:"subscriptionRequest,omitempty"`
+}
+
+// WaffoWebhook 处理 Waffo 回调通知(支付/退款/订阅)
+func WaffoWebhook(c *gin.Context) {
+	if !isWaffoWebhookEnabled() {
+		logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
+		c.AbortWithStatus(http.StatusForbidden)
+		return
+	}
+
+	bodyBytes, err := io.ReadAll(c.Request.Body)
+	if err != nil {
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
+		c.AbortWithStatus(http.StatusBadRequest)
+		return
+	}
+
+	sdk, err := getWaffoSDK()
+	if err != nil {
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook SDK 初始化失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
+		c.AbortWithStatus(http.StatusInternalServerError)
+		return
+	}
+
+	wh := sdk.Webhook()
+	bodyStr := string(bodyBytes)
+	signature := c.GetHeader("X-SIGNATURE")
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
+
+	// 验证请求签名
+	if !wh.VerifySignature(bodyStr, signature) {
+		logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo webhook 验签失败 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
+		c.AbortWithStatus(http.StatusBadRequest)
+		return
+	}
+
+	var event core.WebhookEvent
+	if err := common.Unmarshal(bodyBytes, &event); err != nil {
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook 解析失败 path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), bodyStr))
+		sendWaffoWebhookResponse(c, wh, false, "invalid payload")
+		return
+	}
+
+	switch event.EventType {
+	case core.EventPayment:
+		// 解析为扩展类型,区分普通支付和订阅支付
+		var payload webhookPayloadWithSubInfo
+		if err := common.Unmarshal(bodyBytes, &payload); err != nil {
+			logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 支付回调载荷解析失败 event_type=%s client_ip=%s error=%q body=%q", event.EventType, c.ClientIP(), err.Error(), bodyStr))
+			sendWaffoWebhookResponse(c, wh, false, "invalid payment payload")
+			return
+		}
+		logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 验签并解析成功 event_type=%s merchant_order_id=%s order_status=%s client_ip=%s", event.EventType, payload.Result.MerchantOrderID, payload.Result.OrderStatus, c.ClientIP()))
+		handleWaffoPayment(c, wh, &payload.Result.PaymentNotificationResult)
+	default:
+		logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 忽略事件 event_type=%s client_ip=%s", event.EventType, c.ClientIP()))
+		sendWaffoWebhookResponse(c, wh, true, "")
+	}
+}
+
+// handleWaffoPayment 处理支付完成通知
+func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.PaymentNotificationResult) {
+	if result.OrderStatus != "PAY_SUCCESS" {
+		logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 订单状态非成功,忽略充值 trade_no=%s order_status=%s client_ip=%s", result.MerchantOrderID, result.OrderStatus, c.ClientIP()))
+		// 终态失败订单标记为 failed,避免永远停在 pending
+		if result.MerchantOrderID != "" {
+			if err := model.UpdatePendingTopUpStatus(result.MerchantOrderID, model.PaymentProviderWaffo, common.TopUpStatusFailed); err != nil &&
+				!errors.Is(err, model.ErrTopUpNotFound) &&
+				!errors.Is(err, model.ErrTopUpStatusInvalid) {
+				logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 标记失败订单状态失败 trade_no=%s error=%q", result.MerchantOrderID, err.Error()))
+			}
+		}
+		sendWaffoWebhookResponse(c, wh, true, "")
+		return
+	}
+
+	merchantOrderId := result.MerchantOrderID
+
+	LockOrder(merchantOrderId)
+	defer UnlockOrder(merchantOrderId)
+
+	if err := model.RechargeWaffo(merchantOrderId, c.ClientIP()); err != nil {
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 充值处理失败 trade_no=%s client_ip=%s error=%q", merchantOrderId, c.ClientIP(), err.Error()))
+		sendWaffoWebhookResponse(c, wh, false, err.Error())
+		return
+	}
+
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 充值成功 trade_no=%s client_ip=%s", merchantOrderId, c.ClientIP()))
+	sendWaffoWebhookResponse(c, wh, true, "")
+}
+
+// sendWaffoWebhookResponse 发送签名响应
+func sendWaffoWebhookResponse(c *gin.Context, wh *core.WebhookHandler, success bool, msg string) {
+	var body, sig string
+	if success {
+		body, sig = wh.BuildSuccessResponse()
+	} else {
+		body, sig = wh.BuildFailedResponse(msg)
+	}
+	c.Header("X-SIGNATURE", sig)
+	c.Data(http.StatusOK, "application/json", []byte(body))
+}

+ 260 - 0
controller/topup_waffo_pancake.go

@@ -0,0 +1,260 @@
+package controller
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/logger"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/service"
+	"github.com/QuantumNous/new-api/setting"
+	"github.com/QuantumNous/new-api/setting/operation_setting"
+	"github.com/QuantumNous/new-api/setting/system_setting"
+	"github.com/gin-gonic/gin"
+	"github.com/shopspring/decimal"
+	"github.com/thanhpk/randstr"
+)
+
+type WaffoPancakePayRequest struct {
+	Amount int64 `json:"amount"`
+}
+
+func RequestWaffoPancakeAmount(c *gin.Context) {
+	var req WaffoPancakePayRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
+		return
+	}
+
+	if req.Amount < int64(setting.WaffoPancakeMinTopUp) {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
+		return
+	}
+
+	id := c.GetInt("id")
+	group, err := model.GetUserGroup(id, true)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
+		return
+	}
+
+	payMoney := getWaffoPancakePayMoney(req.Amount, group)
+	if payMoney <= 0.01 {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{"message": "success", "data": fmt.Sprintf("%.2f", payMoney)})
+}
+
+func getWaffoPancakePayMoney(amount int64, group string) float64 {
+	dAmount := decimal.NewFromInt(amount)
+	if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
+		dAmount = dAmount.Div(decimal.NewFromFloat(common.QuotaPerUnit))
+	}
+
+	topupGroupRatio := common.GetTopupGroupRatio(group)
+	if topupGroupRatio == 0 {
+		topupGroupRatio = 1
+	}
+
+	discount := 1.0
+	if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok && ds > 0 {
+		discount = ds
+	}
+
+	payMoney := dAmount.
+		Mul(decimal.NewFromFloat(setting.WaffoPancakeUnitPrice)).
+		Mul(decimal.NewFromFloat(topupGroupRatio)).
+		Mul(decimal.NewFromFloat(discount))
+
+	return payMoney.InexactFloat64()
+}
+
+func normalizeWaffoPancakeTopUpAmount(amount int64) int64 {
+	if operation_setting.GetQuotaDisplayType() != operation_setting.QuotaDisplayTypeTokens {
+		return amount
+	}
+
+	normalized := decimal.NewFromInt(amount).
+		Div(decimal.NewFromFloat(common.QuotaPerUnit)).
+		IntPart()
+	if normalized < 1 {
+		return 1
+	}
+	return normalized
+}
+
+func formatWaffoPancakeAmount(payMoney float64) string {
+	return decimal.NewFromFloat(payMoney).StringFixed(2)
+}
+
+func getWaffoPancakeBuyerEmail(user *model.User) string {
+	if user != nil && strings.TrimSpace(user.Email) != "" {
+		return user.Email
+	}
+	if user != nil {
+		return fmt.Sprintf("%d@new-api.local", user.Id)
+	}
+	return ""
+}
+
+func getWaffoPancakeReturnURL() string {
+	if strings.TrimSpace(setting.WaffoPancakeReturnURL) != "" {
+		return setting.WaffoPancakeReturnURL
+	}
+	return strings.TrimRight(system_setting.ServerAddress, "/") + "/console/topup?show_history=true"
+}
+
+func RequestWaffoPancakePay(c *gin.Context) {
+	if !setting.WaffoPancakeEnabled {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 支付未启用"})
+		return
+	}
+	currentWebhookKey := setting.WaffoPancakeWebhookPublicKey
+	if setting.WaffoPancakeSandbox {
+		currentWebhookKey = setting.WaffoPancakeWebhookTestKey
+	}
+	if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" ||
+		strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" ||
+		strings.TrimSpace(currentWebhookKey) == "" ||
+		strings.TrimSpace(setting.WaffoPancakeStoreID) == "" ||
+		strings.TrimSpace(setting.WaffoPancakeProductID) == "" {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 配置不完整"})
+		return
+	}
+
+	var req WaffoPancakePayRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
+		return
+	}
+	if req.Amount < int64(setting.WaffoPancakeMinTopUp) {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
+		return
+	}
+
+	id := c.GetInt("id")
+	user, err := model.GetUserById(id, false)
+	if err != nil || user == nil {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
+		return
+	}
+
+	group, err := model.GetUserGroup(id, true)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
+		return
+	}
+
+	payMoney := getWaffoPancakePayMoney(req.Amount, group)
+	if payMoney < 0.01 {
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
+		return
+	}
+
+	tradeNo := fmt.Sprintf("WAFFO_PANCAKE-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(6))
+	topUp := &model.TopUp{
+		UserId:          id,
+		Amount:          normalizeWaffoPancakeTopUpAmount(req.Amount),
+		Money:           payMoney,
+		TradeNo:         tradeNo,
+		PaymentMethod:   model.PaymentMethodWaffoPancake,
+		PaymentProvider: model.PaymentProviderWaffoPancake,
+		CreateTime:      time.Now().Unix(),
+		Status:          common.TopUpStatusPending,
+	}
+	if err := topUp.Insert(); err != nil {
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, tradeNo, req.Amount, err.Error()))
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
+		return
+	}
+
+	expiresInSeconds := 45 * 60
+	session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{
+		StoreID:     setting.WaffoPancakeStoreID,
+		ProductID:   setting.WaffoPancakeProductID,
+		ProductType: "onetime",
+		Currency:    strings.ToUpper(strings.TrimSpace(setting.WaffoPancakeCurrency)),
+		PriceSnapshot: &service.WaffoPancakePriceSnapshot{
+			Amount:      formatWaffoPancakeAmount(payMoney),
+			TaxIncluded: false,
+			TaxCategory: "saas",
+		},
+		BuyerEmail:       getWaffoPancakeBuyerEmail(user),
+		SuccessURL:       getWaffoPancakeReturnURL(),
+		ExpiresInSeconds: &expiresInSeconds,
+	})
+	if err != nil {
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error()))
+		topUp.Status = common.TopUpStatusFailed
+		_ = topUp.Update()
+		c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
+		return
+	}
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值订单创建成功 user_id=%d trade_no=%s session_id=%s amount=%d money=%.2f", id, tradeNo, session.SessionID, req.Amount, payMoney))
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "success",
+		"data": gin.H{
+			"checkout_url": session.CheckoutURL,
+			"session_id":   session.SessionID,
+			"expires_at":   session.ExpiresAt,
+			"order_id":     tradeNo,
+		},
+	})
+}
+
+func WaffoPancakeWebhook(c *gin.Context) {
+	if !isWaffoPancakeWebhookEnabled() {
+		logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
+		c.String(http.StatusForbidden, "webhook disabled")
+		return
+	}
+
+	bodyBytes, err := io.ReadAll(c.Request.Body)
+	if err != nil {
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
+		c.String(http.StatusBadRequest, "bad request")
+		return
+	}
+
+	signature := c.GetHeader("X-Waffo-Signature")
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
+
+	event, err := service.VerifyConfiguredWaffoPancakeWebhook(string(bodyBytes), signature)
+	if err != nil {
+		logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签失败 path=%q client_ip=%s signature=%q body=%q error=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes), err.Error()))
+		c.String(http.StatusUnauthorized, "invalid signature")
+		return
+	}
+
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签成功 event_type=%s event_id=%s order_id=%s client_ip=%s", event.NormalizedEventType(), event.ID, event.Data.OrderID, c.ClientIP()))
+	if event.NormalizedEventType() != "order.completed" {
+		c.String(http.StatusOK, "OK")
+		return
+	}
+
+	tradeNo, err := service.ResolveWaffoPancakeTradeNo(event)
+	if err != nil {
+		logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 订单号映射失败 event_id=%s order_id=%s error=%q", event.ID, event.Data.OrderID, err.Error()))
+		c.String(http.StatusOK, "OK")
+		return
+	}
+
+	LockOrder(tradeNo)
+	defer UnlockOrder(tradeNo)
+
+	if err := model.RechargeWaffoPancake(tradeNo); err != nil {
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值处理失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
+		c.String(http.StatusInternalServerError, "retry")
+		return
+	}
+
+	logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值成功 trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
+	c.String(http.StatusOK, "OK")
+}

+ 91 - 0
controller/topup_waffo_pancake_test.go

@@ -0,0 +1,91 @@
+package controller
+
+import (
+	"testing"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/setting"
+	"github.com/QuantumNous/new-api/setting/operation_setting"
+	"github.com/stretchr/testify/require"
+)
+
+func TestFormatWaffoPancakeAmount_UsesDisplayPriceString(t *testing.T) {
+	testCases := []struct {
+		name     string
+		amount   float64
+		expected string
+	}{
+		{name: "whole amount", amount: 29, expected: "29.00"},
+		{name: "decimal amount", amount: 29.9, expected: "29.90"},
+		{name: "round half up to cents", amount: 29.999, expected: "30.00"},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			require.Equal(t, tc.expected, formatWaffoPancakeAmount(tc.amount))
+		})
+	}
+}
+
+func TestGetWaffoPancakePayMoney(t *testing.T) {
+	originalUnitPrice := setting.WaffoPancakeUnitPrice
+	originalQuotaDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType
+	originalDiscounts := make(map[int]float64, len(operation_setting.GetPaymentSetting().AmountDiscount))
+	for k, v := range operation_setting.GetPaymentSetting().AmountDiscount {
+		originalDiscounts[k] = v
+	}
+	originalTopupGroupRatio := common.TopupGroupRatio2JSONString()
+
+	t.Cleanup(func() {
+		setting.WaffoPancakeUnitPrice = originalUnitPrice
+		operation_setting.GetGeneralSetting().QuotaDisplayType = originalQuotaDisplayType
+		operation_setting.GetPaymentSetting().AmountDiscount = originalDiscounts
+		require.NoError(t, common.UpdateTopupGroupRatioByJSONString(originalTopupGroupRatio))
+	})
+
+	setting.WaffoPancakeUnitPrice = 2.5
+	operation_setting.GetPaymentSetting().AmountDiscount = map[int]float64{
+		10:                           0.8,
+		int(common.QuotaPerUnit * 3): 0.5,
+		20:                           0,
+	}
+	require.NoError(t, common.UpdateTopupGroupRatioByJSONString(`{"default":1,"vip":1.2}`))
+
+	testCases := []struct {
+		name             string
+		amount           int64
+		group            string
+		quotaDisplayType string
+		expected         float64
+	}{
+		{
+			name:             "currency display applies unit price group ratio and discount",
+			amount:           10,
+			group:            "vip",
+			quotaDisplayType: operation_setting.QuotaDisplayTypeUSD,
+			expected:         24,
+		},
+		{
+			name:             "tokens display converts quota to display units before pricing",
+			amount:           int64(common.QuotaPerUnit * 3),
+			group:            "vip",
+			quotaDisplayType: operation_setting.QuotaDisplayTypeTokens,
+			expected:         4.5,
+		},
+		{
+			name:             "non-positive discount falls back to no discount",
+			amount:           20,
+			group:            "default",
+			quotaDisplayType: operation_setting.QuotaDisplayTypeUSD,
+			expected:         50,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			operation_setting.GetGeneralSetting().QuotaDisplayType = tc.quotaDisplayType
+			actual := getWaffoPancakePayMoney(tc.amount, tc.group)
+			require.InDelta(t, tc.expected, actual, 0.000001)
+		})
+	}
+}

+ 8 - 4
controller/twofa.go

@@ -2,7 +2,6 @@ package controller
 
 import (
 	"errors"
-	"fmt"
 	"net/http"
 	"strconv"
 
@@ -542,10 +541,15 @@ func AdminDisable2FA(c *gin.Context) {
 		return
 	}
 
-	// 记录操作日志
+	// 记录操作日志:管理员身份通过 admin_info 传递,避免在非管理员可见的日志内容中泄露。
 	adminId := c.GetInt("id")
-	model.RecordLog(userId, model.LogTypeManage,
-		fmt.Sprintf("管理员(ID:%d)强制禁用了用户的两步验证", adminId))
+	adminName := c.GetString("username")
+	adminInfo := map[string]interface{}{
+		"admin_id":       adminId,
+		"admin_username": adminName,
+	}
+	model.RecordLogWithAdminInfo(userId, model.LogTypeManage,
+		"管理员强制禁用了用户的两步验证", adminInfo)
 
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,

+ 15 - 0
controller/usedata.go

@@ -27,6 +27,21 @@ func GetAllQuotaDates(c *gin.Context) {
 	return
 }
 
+func GetQuotaDatesByUser(c *gin.Context) {
+	startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
+	endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
+	dates, err := model.GetQuotaDataGroupByUser(startTimestamp, endTimestamp)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    dates,
+	})
+}
+
 func GetUserQuotaDates(c *gin.Context) {
 	userId := c.GetInt("id")
 	startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)

+ 88 - 9
controller/user.go

@@ -52,10 +52,15 @@ func Login(c *gin.Context) {
 	}
 	err = user.ValidateAndFill()
 	if err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"message": err.Error(),
-			"success": false,
-		})
+		switch {
+		case errors.Is(err, model.ErrDatabase):
+			common.SysLog(fmt.Sprintf("Login database error for user %s: %v", username, err))
+			common.ApiErrorI18n(c, i18n.MsgDatabaseError)
+		case errors.Is(err, model.ErrUserEmptyCredentials):
+			common.ApiErrorI18n(c, i18n.MsgInvalidParams)
+		default:
+			common.ApiErrorI18n(c, i18n.MsgUserUsernameOrPasswordError)
+		}
 		return
 	}
 
@@ -86,6 +91,7 @@ func Login(c *gin.Context) {
 
 // setup session & cookies and then return user info
 func setupLogin(user *model.User, c *gin.Context) {
+	model.UpdateUserLastLoginAt(user.Id)
 	session := sessions.Default(c)
 	session.Set("id", user.Id)
 	session.Set("username", user.Username)
@@ -572,9 +578,6 @@ func UpdateUser(c *gin.Context) {
 		common.ApiError(c, err)
 		return
 	}
-	if originUser.Quota != updatedUser.Quota {
-		model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", logger.LogQuota(originUser.Quota), logger.LogQuota(updatedUser.Quota)))
-	}
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
@@ -841,6 +844,8 @@ func CreateUser(c *gin.Context) {
 type ManageRequest struct {
 	Id     int    `json:"id"`
 	Action string `json:"action"`
+	Value  int    `json:"value"`
+	Mode   string `json:"mode"`
 }
 
 // ManageUser Only admin user can do this
@@ -887,6 +892,11 @@ func ManageUser(c *gin.Context) {
 			})
 			return
 		}
+		// 删除用户后,强制清理 Redis 中所有该用户令牌的缓存,
+		// 避免已缓存的令牌在 TTL 过期前仍能通过 TokenAuth 校验。
+		if err := model.InvalidateUserTokensCache(user.Id); err != nil {
+			common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
+		}
 	case "promote":
 		if myRole != common.RoleRootUser {
 			common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote)
@@ -907,12 +917,71 @@ func ManageUser(c *gin.Context) {
 			return
 		}
 		user.Role = common.RoleCommonUser
+	case "add_quota":
+		adminName := c.GetString("username")
+		adminId := c.GetInt("id")
+		adminInfo := map[string]interface{}{
+			"admin_id":       adminId,
+			"admin_username": adminName,
+		}
+		switch req.Mode {
+		case "add":
+			if req.Value <= 0 {
+				common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
+				return
+			}
+			if err := model.IncreaseUserQuota(user.Id, req.Value, true); err != nil {
+				common.ApiError(c, err)
+				return
+			}
+			model.RecordLogWithAdminInfo(user.Id, model.LogTypeManage,
+				fmt.Sprintf("管理员增加用户额度 %s", logger.LogQuota(req.Value)), adminInfo)
+		case "subtract":
+			if req.Value <= 0 {
+				common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
+				return
+			}
+			if err := model.DecreaseUserQuota(user.Id, req.Value, true); err != nil {
+				common.ApiError(c, err)
+				return
+			}
+			model.RecordLogWithAdminInfo(user.Id, model.LogTypeManage,
+				fmt.Sprintf("管理员减少用户额度 %s", logger.LogQuota(req.Value)), adminInfo)
+		case "override":
+			oldQuota := user.Quota
+			if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil {
+				common.ApiError(c, err)
+				return
+			}
+			model.RecordLogWithAdminInfo(user.Id, model.LogTypeManage,
+				fmt.Sprintf("管理员覆盖用户额度从 %s 为 %s", logger.LogQuota(oldQuota), logger.LogQuota(req.Value)), adminInfo)
+		default:
+			common.ApiErrorI18n(c, i18n.MsgInvalidParams)
+			return
+		}
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": "",
+		})
+		return
 	}
 
 	if err := user.Update(false); err != nil {
 		common.ApiError(c, err)
 		return
 	}
+	// 禁用 / 角色调整后,强制失效用户缓存与其全部令牌缓存,
+	// 避免在 Redis TTL 过期前仍使用旧状态(尤其是禁用后仍可发起请求的问题)。
+	// InvalidateUserCache 会让下一次 GetUserCache 从数据库重新加载,
+	// InvalidateUserTokensCache 则确保令牌侧的缓存也同步刷新。
+	if req.Action == "disable" || req.Action == "promote" || req.Action == "demote" {
+		if err := model.InvalidateUserCache(user.Id); err != nil {
+			common.SysLog(fmt.Sprintf("failed to invalidate user cache for user %d: %s", user.Id, err.Error()))
+		}
+		if err := model.InvalidateUserTokensCache(user.Id); err != nil {
+			common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
+		}
+	}
 	clearUser := model.User{
 		Role:   user.Role,
 		Status: user.Status,
@@ -925,9 +994,19 @@ func ManageUser(c *gin.Context) {
 	return
 }
 
+type emailBindRequest struct {
+	Email string `json:"email"`
+	Code  string `json:"code"`
+}
+
 func EmailBind(c *gin.Context) {
-	email := c.Query("email")
-	code := c.Query("code")
+	var req emailBindRequest
+	if err := common.DecodeJson(c.Request.Body, &req); err != nil {
+		common.ApiError(c, errors.New("invalid request body"))
+		return
+	}
+	email := req.Email
+	code := req.Code
 	if !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) {
 		common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
 		return

+ 11 - 1
controller/video_proxy.go

@@ -10,10 +10,12 @@ import (
 	"strings"
 	"time"
 
+	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/constant"
 	"github.com/QuantumNous/new-api/logger"
 	"github.com/QuantumNous/new-api/model"
 	"github.com/QuantumNous/new-api/service"
+	"github.com/QuantumNous/new-api/setting/system_setting"
 
 	"github.com/gin-gonic/gin"
 )
@@ -35,7 +37,8 @@ func VideoProxy(c *gin.Context) {
 		return
 	}
 
-	task, exists, err := model.GetByOnlyTaskId(taskID)
+	userID := c.GetInt("id")
+	task, exists, err := model.GetByTaskId(userID, taskID)
 	if err != nil {
 		logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to query task %s: %s", taskID, err.Error()))
 		videoProxyError(c, http.StatusInternalServerError, "server_error", "Failed to query task")
@@ -126,6 +129,13 @@ func VideoProxy(c *gin.Context) {
 		return
 	}
 
+	fetchSetting := system_setting.GetFetchSetting()
+	if err := common.ValidateURLWithFetchSetting(videoURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
+		logger.LogError(c.Request.Context(), fmt.Sprintf("Video URL blocked for task %s: %v", taskID, err))
+		videoProxyError(c, http.StatusForbidden, "server_error", fmt.Sprintf("request blocked: %v", err))
+		return
+	}
+
 	req.URL, err = url.Parse(videoURL)
 	if err != nil {
 		logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to parse URL %s: %s", videoURL, err.Error()))

+ 15 - 2
controller/wechat.go

@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
+	"net/url"
 	"strconv"
 	"time"
 
@@ -25,7 +26,7 @@ func getWeChatIdByCode(code string) (string, error) {
 	if code == "" {
 		return "", errors.New("无效的参数")
 	}
-	req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, code), nil)
+	req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, url.QueryEscape(code)), nil)
 	if err != nil {
 		return "", err
 	}
@@ -121,6 +122,10 @@ func WeChatAuth(c *gin.Context) {
 	setupLogin(&user, c)
 }
 
+type wechatBindRequest struct {
+	Code string `json:"code"`
+}
+
 func WeChatBind(c *gin.Context) {
 	if !common.WeChatAuthEnabled {
 		c.JSON(http.StatusOK, gin.H{
@@ -129,7 +134,15 @@ func WeChatBind(c *gin.Context) {
 		})
 		return
 	}
-	code := c.Query("code")
+	var req wechatBindRequest
+	if err := common.DecodeJson(c.Request.Body, &req); err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "无效的请求",
+		})
+		return
+	}
+	code := req.Code
 	wechatId, err := getWeChatIdByCode(code)
 	if err != nil {
 		c.JSON(http.StatusOK, gin.H{

+ 73 - 0
docker-compose.dev.yml

@@ -0,0 +1,73 @@
+# Frontend Development - Backend built from local source
+#
+# Usage:
+#   1. docker compose -f docker-compose.dev.yml up -d
+#   2. cd web && bun install && bun run dev
+#   3. Open http://localhost:3001 (Rsbuild dev server, API auto-proxied to :3000)
+#
+# Rebuild backend after Go code changes:
+#   docker compose -f docker-compose.dev.yml up -d --build new-api
+#
+# Stop:
+#   docker compose -f docker-compose.dev.yml down
+#
+# Reset data:
+#   docker compose -f docker-compose.dev.yml down -v
+
+services:
+  new-api:
+    build:
+      context: .
+      dockerfile: Dockerfile.dev
+    image: new-api-dev:local
+    container_name: new-api-dev
+    restart: unless-stopped
+    ports:
+      - "3000:3000"
+    volumes:
+      - dev_data:/data
+    environment:
+      - SQL_DSN=postgresql://root:123456@postgres:5432/new-api
+      - REDIS_CONN_STRING=redis://redis
+      - TZ=Asia/Shanghai
+      - BATCH_UPDATE_ENABLED=true
+    depends_on:
+      redis:
+        condition: service_started
+      postgres:
+        condition: service_healthy
+    networks:
+      - dev-network
+
+  redis:
+    image: redis:7-alpine
+    container_name: new-api-dev-redis
+    restart: unless-stopped
+    networks:
+      - dev-network
+
+  postgres:
+    image: postgres:15-alpine
+    container_name: new-api-dev-pg
+    restart: unless-stopped
+    environment:
+      POSTGRES_USER: root
+      POSTGRES_PASSWORD: 123456
+      POSTGRES_DB: new-api
+    volumes:
+      - dev_pg_data:/var/lib/postgresql/data
+    networks:
+      - dev-network
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U root -d new-api"]
+      interval: 5s
+      timeout: 3s
+      retries: 5
+
+volumes:
+  dev_data:
+  dev_pg_data:
+
+networks:
+  dev-network:
+    driver: bridge

+ 46 - 45
docker-compose.yml

@@ -5,18 +5,18 @@
 #   2. Access at http://localhost:3000
 #
 # Using MySQL instead of PostgreSQL:
-#   1. Comment out the postgres service and SQL_DSN line 29
-#   2. Uncomment the mysql service and SQL_DSN line 30
-#   3. Uncomment mysql in depends_on (line 46)
-#   4. Uncomment mysql_data in volumes section (line 85)
+#   1. Comment out the postgres service and SQL_DSN line 15
+#   2. Uncomment the mysql service and SQL_DSN line 16
+#   3. Uncomment mysql in depends_on (line 28)
+#   4. Uncomment mysql_data in volumes section (line 64)
 #
 # ⚠️  IMPORTANT: Change all default passwords before deploying to production!
 
+version: '3.4' # For compatibility with older Docker versions
+
 services:
   new-api:
-    build:
-      context: .
-      dockerfile: Dockerfile
+    image: calciumion/new-api:latest
     container_name: new-api
     restart: always
     command: --log-dir /app/logs
@@ -26,56 +26,52 @@ services:
       - ./data:/data
       - ./logs:/app/logs
     environment:
-      # 阿里云 MySQL 连接配置 (Aliyun MySQL Connection)
-      # 格式: username:password@tcp(host:port)/database
-      # 示例: - SQL_DSN=root:your_password@tcp(rm-xxxxx.mysql.rds.aliyuncs.com:3306)/new-api
-      - SQL_DSN=ai_rw:ws8oRahcYm5GwkQy@tcp(mr-y9gker44xqhwnrnv59.rwlb.singapore.rds.aliyuncs.com:3306)/new-api  # ⚠️ 请替换为您的阿里云 MySQL 连接信息
-
-      # 阿里云 Redis 连接配置 (Aliyun Redis Connection)
-      # 格式: redis://:password@host:port/db
-      # 示例: - REDIS_CONN_STRING=redis://:your_password@r-xxxxx.redis.rds.aliyuncs.com:6379/0
-      - REDIS_CONN_STRING=redis://:RPDUyra6szoS0LA1@r-t4n74k8muxmjo181qd.redis.singapore.rds.aliyuncs.com:6379/0  # ⚠️ 请替换为您的阿里云 Redis 连接信息
+      - SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production!
+#      - SQL_DSN=root:123456@tcp(mysql:3306)/new-api  # Point to the mysql service, uncomment if using MySQL
+      - REDIS_CONN_STRING=redis://:123456@redis:6379 # ⚠️ IMPORTANT: Change the password in production!
       - TZ=Asia/Shanghai
       - ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording)
       - BATCH_UPDATE_ENABLED=true  # 是否启用批量更新 (Whether to enable batch update)
-      - MAX_REQUEST_BODY_MB=500  # 请求体最大大小(MB),用于支持大文件上传 (Max request body size in MB for large file uploads)
-      - STREAMING_TIMEOUT=300  # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions)
+      - NODE_NAME=new-api-node-1  # 节点名称,用于审计日志中标识节点身份;多节点/容器部署时建议设置 (Node name used in audit logs; recommended when running multiple instances or in containers)
+#      - STREAMING_TIMEOUT=300  # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions)
 #      - SESSION_SECRET=random_string  # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!)
 #      - SYNC_FREQUENCY=60  # Uncomment if regular database syncing is needed
 #      - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX  # Google Analytics 的测量 ID (Google Analytics Measurement ID)
 #      - UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx  # Umami 网站 ID (Umami Website ID)
 #      - UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js  # Umami 脚本 URL,默认为官方地址 (Umami Script URL, defaults to official URL)
 
-    # 使用外部阿里云服务时,不需要依赖本地容器 (No local container dependencies when using external Aliyun services)
-    # depends_on:
-    #   - redis
-    #   - postgres
+    depends_on:
+      - redis
+      - postgres
+#      - mysql  # Uncomment if using MySQL
+    networks:
+      - new-api-network
     healthcheck:
       test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]
       interval: 30s
       timeout: 10s
       retries: 3
 
-# ============================================================
-# 以下本地服务已禁用,因为使用外部阿里云服务
-# Local services disabled - using external Aliyun services
-# ============================================================
+  redis:
+    image: redis:latest
+    container_name: redis
+    restart: always
+    command: ["redis-server", "--requirepass", "123456"]  # ⚠️ IMPORTANT: Change this password in production!
+    networks:
+      - new-api-network
 
-#  redis:
-#    image: redis:latest
-#    container_name: redis
-#    restart: always
-#
-#  postgres:
-#    image: postgres:15
-#    container_name: postgres
-#    restart: always
-#    environment:
-#      POSTGRES_USER: root
-#      POSTGRES_PASSWORD: 123456  # ⚠️ IMPORTANT: Change this password in production!
-#      POSTGRES_DB: new-api
-#    volumes:
-#      - pg_data:/var/lib/postgresql/data
+  postgres:
+    image: postgres:15
+    container_name: postgres
+    restart: always
+    environment:
+      POSTGRES_USER: root
+      POSTGRES_PASSWORD: 123456  # ⚠️ IMPORTANT: Change this password in production!
+      POSTGRES_DB: new-api
+    volumes:
+      - pg_data:/var/lib/postgresql/data
+    networks:
+      - new-api-network
 #    ports:
 #      - "5432:5432"  # Uncomment if you need to access PostgreSQL from outside Docker
 
@@ -88,10 +84,15 @@ services:
 #      MYSQL_DATABASE: new-api
 #    volumes:
 #      - mysql_data:/var/lib/mysql
+#    networks:
+#      - new-api-network
 #    ports:
 #      - "3306:3306"  # Uncomment if you need to access MySQL from outside Docker
 
-# 使用外部阿里云服务时不需要本地数据卷 (No local volumes needed when using external Aliyun services)
-# volumes:
-#   pg_data:
-#   mysql_data:
+volumes:
+  pg_data:
+#  mysql_data:
+
+networks:
+  new-api-network:
+    driver: bridge

+ 150 - 2
docs/installation/BT.md

@@ -1,3 +1,151 @@
-密钥为环境变量SESSION_SECRET
+# 宝塔面板部署教程
+
+本文档提供使用宝塔面板 Docker 功能部署 New API 的图文教程。
+
+> 📖 官方文档:[宝塔面板部署](https://docs.newapi.pro/zh/docs/installation/deployment-methods/bt-docker-installation)
+
+***
+
+## 前置要求
+
+| 项目    | 要求                                 |
+| ----- | ---------------------------------- |
+| 宝塔面板  | ≥ 9.2.0 版本                         |
+| 推荐系统  | CentOS 7+、Ubuntu 18.04+、Debian 10+ |
+| 服务器配置 | 至少 1 核 2G 内存                       |
+
+***
+
+## 步骤一:安装宝塔面板
+
+1. 前往 [宝塔面板官网](https://www.bt.cn/new/download.html) 下载适合您系统的安装脚本
+2. 运行安装脚本安装宝塔面板
+3. 安装完成后,使用提供的地址、用户名和密码登录宝塔面板
+
+***
+
+## 步骤二:安装 Docker
+
+1. 登录宝塔面板后,在左侧菜单栏找到并点击 **Docker**
+2. 首次进入会提示安装 Docker 服务,点击 **立即安装**
+3. 按照提示完成 Docker 服务的安装
+
+***
+
+## 步骤三:安装 New API
+
+### 方法一:使用宝塔应用商店(推荐)
+
+1. 在宝塔面板 Docker 功能中,点击 **应用商店**
+2. 搜索并找到 **New-API**
+3. 点击 **安装**
+4. 配置以下基本选项:
+   - **容器名称**:可自定义,默认为 `new-api`
+   - **端口映射**:默认为 `3000:3000`
+   - **环境变量**:
+     - `SESSION_SECRET`:会话密钥(**必填**,多机部署时必须一致)
+     - `CRYPTO_SECRET`:加密密钥(使用 Redis 时必填)
+5. 点击 **确认** 开始安装
+6. 等待安装完成后,访问 `http://您的服务器IP:3000` 即可使用
+
+### 方法二:使用 Docker Compose
+
+1. 在宝塔面板中创建网站目录,如 `/www/wwwroot/new-api`
+2. 创建 `docker-compose.yml` 文件:
+
+```yaml
+version: '3'
+services:
+  new-api:
+    image: calciumion/new-api:latest
+    container_name: new-api
+    restart: always
+    ports:
+      - "3000:3000"
+    volumes:
+      - ./data:/data
+    environment:
+      - SESSION_SECRET=your_session_secret_here  # 请修改为随机字符串
+      - TZ=Asia/Shanghai
+```
+
+1. 在终端中进入目录并启动:
+
+```bash
+cd /www/wwwroot/new-api
+docker-compose up -d
+```
+
+***
+
+## 配置说明
+
+### 必要环境变量
+
+| 变量名                 | 说明                 | 是否必填   |
+| ------------------- | ------------------ | ------ |
+| `SESSION_SECRET`    | 会话密钥,多机部署必须一致      | **必填** |
+| `CRYPTO_SECRET`     | 加密密钥,使用 Redis 时必填  | 条件必填   |
+| `SQL_DSN`           | 数据库连接字符串(使用外部数据库时) | 可选     |
+| `REDIS_CONN_STRING` | Redis 连接字符串        | 可选     |
+
+### 生成随机密钥
+
+```bash
+# 生成 SESSION_SECRET
+openssl rand -hex 16
+
+# 或使用 Linux 命令
+head -c 16 /dev/urandom | xxd -p
+```
+
+***
+
+## 常见问题
+
+### Q1:无法访问 3000 端口?
+
+1. 检查服务器防火墙是否开放 3000 端口
+2. 在宝塔面板 **安全** 中放行 3000 端口
+3. 检查云服务器安全组是否开放端口
+
+### Q2:登录后提示会话失效?
+
+确保设置了 `SESSION_SECRET` 环境变量,且值不为空。
+
+### Q3:数据如何持久化?
+
+使用 Docker 卷映射数据目录:
+
+```yaml
+volumes:
+  - ./data:/data
+```
+
+### Q4:如何更新版本?
+
+```bash
+# 拉取最新镜像
+docker pull calciumion/new-api:latest
+
+# 重启容器
+docker-compose down && docker-compose up -d
+```
+
+***
+
+## 相关链接
+
+- [官方文档](https://docs.newapi.pro/zh/docs/installation)
+- [环境变量配置](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables)
+- [常见问题](https://docs.newapi.pro/zh/docs/support/faq)
+- [GitHub 仓库](https://github.com/QuantumNous/new-api)
+
+***
+
+## 截图示例
+
+![宝塔面板 Docker 安装](https://github.com/user-attachments/assets/7a6fc03e-c457-45e4-b8f9-184508fc26b0)
+
+> ⚠️ 注意:密钥为环境变量 `SESSION_SECRET`,请务必设置!
 
-![8285bba413e770fe9620f1bf9b40d44e](https://github.com/user-attachments/assets/7a6fc03e-c457-45e4-b8f9-184508fc26b0)

+ 53 - 1
docs/openapi/relay.json

@@ -3281,6 +3281,13 @@
               }
             ]
           },
+          "cache_control": {
+            "type": "object",
+            "properties": {}
+          },
+          "inference_geo": {
+            "type": "string"
+          },
           "max_tokens": {
             "type": "integer",
             "minimum": 1
@@ -3333,7 +3340,8 @@
                     "enum": [
                       "auto",
                       "any",
-                      "tool"
+                      "tool",
+                      "none"
                     ]
                   },
                   "name": {
@@ -3358,6 +3366,36 @@
               }
             }
           },
+          "context_management": {
+            "type": "object",
+            "properties": {}
+          },
+          "output_config": {
+            "type": "object",
+            "properties": {}
+          },
+          "output_format": {
+            "type": "object",
+            "properties": {}
+          },
+          "container": {
+            "oneOf": [
+              {
+                "type": "string"
+              },
+              {
+                "type": "object",
+                "properties": {}
+              }
+            ]
+          },
+          "mcp_servers": {
+            "type": "array",
+            "items": {
+              "type": "object",
+              "properties": {}
+            }
+          },
           "metadata": {
             "type": "object",
             "properties": {
@@ -3365,6 +3403,20 @@
                 "type": "string"
               }
             }
+          },
+          "speed": {
+            "type": "string",
+            "enum": [
+              "standard",
+              "fast"
+            ]
+          },
+          "service_tier": {
+            "type": "string",
+            "enum": [
+              "auto",
+              "standard_only"
+            ]
           }
         }
       },

+ 10 - 0
dto/audio.go

@@ -18,6 +18,16 @@ type AudioRequest struct {
 	Speed          *float64        `json:"speed,omitempty"`
 	StreamFormat   string          `json:"stream_format,omitempty"`
 	Metadata       json.RawMessage `json:"metadata,omitempty"`
+	// vllm-omini
+	TaskType                json.RawMessage `json:"task_type,omitempty"`
+	Language                json.RawMessage `json:"language,omitempty"`
+	RefAudio                json.RawMessage `json:"ref_audio,omitempty"`
+	RefText                 json.RawMessage `json:"ref_text,omitempty"`
+	XVectorOnlyMode         json.RawMessage `json:"x_vector_only_mode,omitempty"`
+	MaxNewTokens            json.RawMessage `json:"max_new_tokens,omitempty"`
+	InitialCodecChunkFrames json.RawMessage `json:"initial_codec_chunk_frames,omitempty"`
+	// TODO:ensure that the logic remains correct after the stream is started.
+	//Stream                  json.RawMessage `json:"stream,omitempty"`
 }
 
 func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {

+ 1 - 0
dto/channel_settings.go

@@ -30,6 +30,7 @@ type ChannelOtherSettings struct {
 	ClaudeBetaQuery                       bool          `json:"claude_beta_query,omitempty"`         // Claude 渠道是否强制追加 ?beta=true
 	AllowServiceTier                      bool          `json:"allow_service_tier,omitempty"`        // 是否允许 service_tier 透传(默认过滤以避免额外计费)
 	AllowInferenceGeo                     bool          `json:"allow_inference_geo,omitempty"`       // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规
+	AllowSpeed                            bool          `json:"allow_speed,omitempty"`               // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式)
 	AllowSafetyIdentifier                 bool          `json:"allow_safety_identifier,omitempty"`   // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
 	DisableStore                          bool          `json:"disable_store,omitempty"`             // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
 	AllowIncludeObfuscation               bool          `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)

+ 49 - 32
dto/claude.go

@@ -98,6 +98,20 @@ func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage {
 	return mediaContent
 }
 
+func (m *ClaudeMediaMessage) ToFileSource() types.FileSource {
+	if m.Source == nil {
+		return nil
+	}
+	data := m.Source.Url
+	if data == "" {
+		data = common.Interface2String(m.Source.Data)
+	}
+	if data == "" {
+		return nil
+	}
+	return types.NewFileSourceFromData(data, m.Source.MediaType)
+}
+
 type ClaudeMessageSource struct {
 	Type      string `json:"type"`
 	MediaType string `json:"media_type,omitempty"`
@@ -190,10 +204,11 @@ type ClaudeToolChoice struct {
 }
 
 type ClaudeRequest struct {
-	Model    string          `json:"model"`
-	Prompt   string          `json:"prompt,omitempty"`
-	System   any             `json:"system,omitempty"`
-	Messages []ClaudeMessage `json:"messages,omitempty"`
+	Model        string          `json:"model"`
+	Prompt       string          `json:"prompt,omitempty"`
+	System       any             `json:"system,omitempty"`
+	Messages     []ClaudeMessage `json:"messages,omitempty"`
+	CacheControl json.RawMessage `json:"cache_control,omitempty"`
 	// InferenceGeo controls Claude data residency region.
 	// This field is filtered by default and can be enabled via channel setting allow_inference_geo.
 	InferenceGeo      string          `json:"inference_geo,omitempty"`
@@ -213,17 +228,17 @@ type ClaudeRequest struct {
 	Thinking          *Thinking       `json:"thinking,omitempty"`
 	McpServers        json.RawMessage `json:"mcp_servers,omitempty"`
 	Metadata          json.RawMessage `json:"metadata,omitempty"`
+	// Speed specifies the Claude inference speed mode.
+	// This field is filtered by default and can be enabled via channel setting allow_speed.
+	Speed json.RawMessage `json:"speed,omitempty"`
 	// ServiceTier specifies upstream service level and may affect billing.
 	// This field is filtered by default and can be enabled via channel setting allow_service_tier.
 	ServiceTier string `json:"service_tier,omitempty"`
 }
 
-// createClaudeFileSource 根据数据内容创建正确类型的 FileSource
-func createClaudeFileSource(data string) *types.FileSource {
-	if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
-		return types.NewURLFileSource(data)
-	}
-	return types.NewBase64FileSource(data, "")
+// OutputConfigForEffort just for extract effort
+type OutputConfigForEffort struct {
+	Effort string `json:"effort,omitempty"`
 }
 
 func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
@@ -253,17 +268,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
 				case "text":
 					texts = append(texts, media.GetText())
 				case "image":
-					if media.Source != nil {
-						data := media.Source.Url
-						if data == "" {
-							data = common.Interface2String(media.Source.Data)
-						}
-						if data != "" {
-							fileMeta = append(fileMeta, &types.FileMeta{
-								FileType: types.FileTypeImage,
-								Source:   createClaudeFileSource(data),
-							})
-						}
+					if source := media.ToFileSource(); source != nil {
+						fileMeta = append(fileMeta, &types.FileMeta{
+							FileType: types.FileTypeImage,
+							Source:   source,
+						})
 					}
 				}
 			}
@@ -288,17 +297,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
 			case "text":
 				texts = append(texts, media.GetText())
 			case "image":
-				if media.Source != nil {
-					data := media.Source.Url
-					if data == "" {
-						data = common.Interface2String(media.Source.Data)
-					}
-					if data != "" {
-						fileMeta = append(fileMeta, &types.FileMeta{
-							FileType: types.FileTypeImage,
-							Source:   createClaudeFileSource(data),
-						})
-					}
+				if source := media.ToFileSource(); source != nil {
+					fileMeta = append(fileMeta, &types.FileMeta{
+						FileType: types.FileTypeImage,
+						Source:   source,
+					})
 				}
 			case "tool_use":
 				if media.Name != "" {
@@ -409,6 +412,15 @@ func (c *ClaudeRequest) GetTools() []any {
 	}
 }
 
+func (c *ClaudeRequest) GetEfforts() string {
+	var OutputConfig OutputConfigForEffort
+	if err := json.Unmarshal(c.OutputConfig, &OutputConfig); err == nil {
+		effort := OutputConfig.Effort
+		return effort
+	}
+	return ""
+}
+
 // ProcessTools 处理工具列表,支持类型断言
 func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) {
 	var normalTools []*Tool
@@ -436,6 +448,11 @@ func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) {
 type Thinking struct {
 	Type         string `json:"type,omitempty"`
 	BudgetTokens *int   `json:"budget_tokens,omitempty"`
+	// Display controls whether thinking content is returned in the response.
+	// Used with adaptive thinking on Claude Opus 4.7+: "summarized" restores
+	// the visible summary that was default on Opus 4.6; "omitted" (default on
+	// 4.7) suppresses it. Pass-through field from upstream Anthropic API.
+	Display string `json:"display,omitempty"`
 }
 
 func (c *Thinking) GetBudgetTokens() int {

+ 15 - 46
dto/gemini.go

@@ -46,6 +46,7 @@ func (r *GeminiChatRequest) UnmarshalJSON(data []byte) error {
 type ToolConfig struct {
 	FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
 	RetrievalConfig       *RetrievalConfig       `json:"retrievalConfig,omitempty"`
+	IncludeServerSideToolInvocations *bool       `json:"includeServerSideToolInvocations,omitempty"`
 }
 
 type FunctionCallingConfig struct {
@@ -64,14 +65,6 @@ type LatLng struct {
 	Longitude *float64 `json:"longitude,omitempty"`
 }
 
-// createGeminiFileSource 根据数据内容创建正确类型的 FileSource
-func createGeminiFileSource(data string, mimeType string) *types.FileSource {
-	if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
-		return types.NewURLFileSource(data)
-	}
-	return types.NewBase64FileSource(data, mimeType)
-}
-
 func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
 	var files []*types.FileMeta = make([]*types.FileMeta, 0)
 
@@ -87,9 +80,8 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
 			if part.Text != "" {
 				inputTexts = append(inputTexts, part.Text)
 			}
-			if part.InlineData != nil && part.InlineData.Data != "" {
+			if source := part.InlineData.ToFileSource(); source != nil {
 				mimeType := part.InlineData.MimeType
-				source := createGeminiFileSource(part.InlineData.Data, mimeType)
 				var fileType types.FileType
 				if strings.HasPrefix(mimeType, "image/") {
 					fileType = types.FileTypeImage
@@ -103,7 +95,6 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
 				files = append(files, &types.FileMeta{
 					FileType: fileType,
 					Source:   source,
-					MimeType: mimeType,
 				})
 			}
 		}
@@ -121,6 +112,11 @@ func (r *GeminiChatRequest) IsStream(c *gin.Context) bool {
 	if c.Query("alt") == "sse" {
 		return true
 	}
+	// Native Gemini API uses URL action to indicate streaming:
+	// /v1beta/models/{model}:streamGenerateContent
+	if strings.Contains(c.Request.URL.Path, "streamGenerateContent") {
+		return true
+	}
 	return false
 }
 
@@ -210,6 +206,13 @@ type GeminiInlineData struct {
 	Data     string `json:"data"`
 }
 
+func (d *GeminiInlineData) ToFileSource() types.FileSource {
+	if d == nil || d.Data == "" {
+		return nil
+	}
+	return types.NewFileSourceFromData(d.Data, d.MimeType)
+}
+
 // UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType
 func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
 	type Alias GeminiInlineData // Use type alias to avoid recursion
@@ -466,6 +469,7 @@ type GeminiUsageMetadata struct {
 	CachedContentTokenCount    int                         `json:"cachedContentTokenCount"`
 	PromptTokensDetails        []GeminiPromptTokensDetails `json:"promptTokensDetails"`
 	ToolUsePromptTokensDetails []GeminiPromptTokensDetails `json:"toolUsePromptTokensDetails"`
+	CandidatesTokensDetails    []GeminiPromptTokensDetails `json:"candidatesTokensDetails"`
 }
 
 type GeminiPromptTokensDetails struct {
@@ -576,38 +580,3 @@ type GeminiBatchEmbeddingResponse struct {
 type ContentEmbedding struct {
 	Values []float64 `json:"values"`
 }
-
-// File API related structs
-type GeminiFileUploadResponse struct {
-	File GeminiFile `json:"file"`
-}
-
-type GeminiFile struct {
-	Name           string            `json:"name"`
-	DisplayName    string            `json:"displayName,omitempty"`
-	MimeType       string            `json:"mimeType"`
-	SizeBytes      string            `json:"sizeBytes"`
-	CreateTime     string            `json:"createTime"`
-	UpdateTime     string            `json:"updateTime"`
-	ExpirationTime string            `json:"expirationTime,omitempty"`
-	Sha256Hash     string            `json:"sha256Hash,omitempty"`
-	Uri            string            `json:"uri"`
-	State          string            `json:"state"`
-	Error          *GeminiFileError  `json:"error,omitempty"`
-	VideoMetadata  *GeminiVideoMeta  `json:"videoMetadata,omitempty"`
-}
-
-type GeminiVideoMeta struct {
-	VideoDuration string `json:"videoDuration,omitempty"`
-}
-
-type GeminiFileListResponse struct {
-	Files         []GeminiFile `json:"files"`
-	NextPageToken string       `json:"nextPageToken,omitempty"`
-}
-
-type GeminiFileError struct {
-	Code    int    `json:"code"`
-	Message string `json:"message"`
-	Status  string `json:"status"`
-}

+ 73 - 0
dto/gemini_isstream_test.go

@@ -0,0 +1,73 @@
+package dto
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGeminiChatRequest_IsStream(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+
+	tests := []struct {
+		name     string
+		path     string
+		query    string
+		expected bool
+	}{
+		{
+			name:     "streamGenerateContent without alt=sse",
+			path:     "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
+			query:    "key=sk-xxx",
+			expected: true,
+		},
+		{
+			name:     "streamGenerateContent with alt=sse",
+			path:     "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
+			query:    "alt=sse&key=sk-xxx",
+			expected: true,
+		},
+		{
+			name:     "generateContent without alt=sse",
+			path:     "/v1beta/models/gemini-2.0-flash:generateContent",
+			query:    "key=sk-xxx",
+			expected: false,
+		},
+		{
+			name:     "generateContent with alt=sse",
+			path:     "/v1beta/models/gemini-2.0-flash:generateContent",
+			query:    "alt=sse",
+			expected: true,
+		},
+		{
+			name:     "GenerateContent capitalized",
+			path:     "/v1beta/models/gemini-2.0-flash:GenerateContent",
+			query:    "key=sk-xxx",
+			expected: false,
+		},
+		{
+			name:     "embedding path",
+			path:     "/v1beta/models/gemini-2.0-flash:embedContent",
+			query:    "",
+			expected: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+			url := tt.path
+			if tt.query != "" {
+				url += "?" + tt.query
+			}
+			c.Request, _ = http.NewRequest("POST", url, nil)
+
+			req := &GeminiChatRequest{}
+			assert.Equal(t, tt.expected, req.IsStream(c))
+		})
+	}
+}

+ 9 - 7
dto/openai_image.go

@@ -27,7 +27,10 @@ type ImageRequest struct {
 	OutputCompression json.RawMessage `json:"output_compression,omitempty"`
 	PartialImages     json.RawMessage `json:"partial_images,omitempty"`
 	// Stream            bool            `json:"stream,omitempty"`
-	Watermark *bool `json:"watermark,omitempty"`
+	Images        json.RawMessage `json:"images,omitempty"`
+	Mask          json.RawMessage `json:"mask,omitempty"`
+	InputFidelity json.RawMessage `json:"input_fidelity,omitempty"`
+	Watermark     *bool           `json:"watermark,omitempty"`
 	// zhipu 4v
 	WatermarkEnabled json.RawMessage `json:"watermark_enabled,omitempty"`
 	UserId           json.RawMessage `json:"user_id,omitempty"`
@@ -148,15 +151,14 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
 		}
 	}
 
-	// not support token count for dalle
-	n := uint(1)
-	if i.N != nil {
-		n = *i.N
-	}
+	// n is NOT included here; it is handled via OtherRatio("n") in
+	// image_handler.go (default) or channel adaptors (actual count).
+	// Including n here caused double-counting for channels that also
+	// set OtherRatio("n") (e.g. Ali/Bailian).
 	return &types.TokenCountMeta{
 		CombineText:     i.Prompt,
 		MaxTokens:       1584,
-		ImagePriceRatio: sizeRatio * qualityRatio * float64(n),
+		ImagePriceRatio: sizeRatio * qualityRatio,
 	}
 }
 

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor