Compare commits
25 Commits
34afe9b426
...
8cf63b5db2
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cf63b5db2 | |||
| 30f585822b | |||
| fc377dae3e | |||
| df14a0bf18 | |||
| c609cb13b2 | |||
| a42b397607 | |||
| 9f8a4ec050 | |||
| bee339d279 | |||
| 4e93148d9e | |||
| e36d191c2e | |||
| 02aacb38a2 | |||
| c60fd7074a | |||
| 198e723b97 | |||
| 06f9bde3c9 | |||
| 922066b138 | |||
| 3163a268cb | |||
| 0818d33384 | |||
| 8967a69af9 | |||
| 183e95468c | |||
| de5791834f | |||
| 1b45ec37f1 | |||
| 24fb4bdc44 | |||
| bfb29ef137 | |||
| 3e7d353f80 | |||
| c538ebd62f |
@@ -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 + Radix 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.)
|
||||||
|
- Radix 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
|
||||||
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Publish Docker image (Multi Registries, native amd64+arm64)
|
name: Publish Docker image (Multi-arch)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -14,7 +14,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_single_arch:
|
build_single_arch:
|
||||||
name: Build & push (${{ matrix.arch }}) [native]
|
name: Build & push (${{ matrix.arch }})
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -26,6 +26,8 @@ jobs:
|
|||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runner: ubuntu-24.04-arm
|
runner: ubuntu-24.04-arm
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
|
outputs:
|
||||||
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
@@ -34,58 +36,46 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }}
|
fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }}
|
||||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
- name: Resolve tag & write VERSION
|
- name: Resolve tag & write VERSION
|
||||||
|
id: version
|
||||||
run: |
|
run: |
|
||||||
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
||||||
TAG="${{ github.event.inputs.tag }}"
|
TAG="${{ github.event.inputs.tag }}"
|
||||||
# Verify tag exists
|
|
||||||
if ! git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then
|
if ! git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then
|
||||||
echo "Error: Tag '$TAG' does not exist in the repository"
|
echo "::error::Tag '$TAG' does not exist"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
TAG=${GITHUB_REF#refs/tags/}
|
TAG=${GITHUB_REF#refs/tags/}
|
||||||
fi
|
fi
|
||||||
echo "TAG=$TAG" >> $GITHUB_ENV
|
echo "TAG=${TAG}" >> $GITHUB_ENV
|
||||||
echo "$TAG" > VERSION
|
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||||
echo "Building tag: $TAG for ${{ matrix.arch }}"
|
echo "${TAG}" > VERSION
|
||||||
|
echo "Building tag: ${TAG} for ${{ matrix.arch }}"
|
||||||
|
|
||||||
# - name: Normalize GHCR repository
|
|
||||||
# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
# - name: Log in to GHCR
|
|
||||||
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
|
||||||
# with:
|
|
||||||
# registry: ghcr.io
|
|
||||||
# username: ${{ github.actor }}
|
|
||||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata (labels)
|
- name: Extract metadata (labels)
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: calciumion/new-api
|
||||||
calciumion/new-api
|
|
||||||
# ghcr.io/${{ env.GHCR_REPOSITORY }}
|
|
||||||
|
|
||||||
- name: Build & push single-arch (to both registries)
|
- name: Build & push
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
@@ -93,8 +83,6 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }}
|
calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }}
|
||||||
calciumion/new-api:latest-${{ matrix.arch }}
|
calciumion/new-api:latest-${{ matrix.arch }}
|
||||||
# ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.TAG }}-${{ matrix.arch }}
|
|
||||||
# ghcr.io/${{ env.GHCR_REPOSITORY }}:latest-${{ matrix.arch }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
@@ -102,81 +90,52 @@ jobs:
|
|||||||
sbom: true
|
sbom: true
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
|
uses: sigstore/cosign-installer@v3
|
||||||
|
|
||||||
- name: Sign image with cosign
|
- name: Sign image with cosign
|
||||||
run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
|
run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
|
||||||
|
|
||||||
- name: Output digest
|
- name: Image summary
|
||||||
run: |
|
run: |
|
||||||
echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY
|
echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY
|
||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
echo "calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
|
echo "calciumion/new-api:${TAG}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY
|
echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
create_manifests:
|
create_manifests:
|
||||||
name: Create multi-arch manifests (Docker Hub)
|
name: Create multi-arch manifests
|
||||||
needs: [build_single_arch]
|
needs: [build_single_arch]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
|
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Extract tag
|
- name: Set version
|
||||||
run: |
|
run: echo "TAG=${{ needs.build_single_arch.outputs.tag }}" >> $GITHUB_ENV
|
||||||
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
|
||||||
echo "TAG=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
#
|
|
||||||
# - name: Normalize GHCR repository
|
|
||||||
# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create & push manifest (Docker Hub - version)
|
- name: Create & push manifest (version)
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
-t calciumion/new-api:${TAG} \
|
-t calciumion/new-api:${TAG} \
|
||||||
calciumion/new-api:${TAG}-amd64 \
|
calciumion/new-api:${TAG}-amd64 \
|
||||||
calciumion/new-api:${TAG}-arm64
|
calciumion/new-api:${TAG}-arm64
|
||||||
|
|
||||||
- name: Create & push manifest (Docker Hub - latest)
|
- name: Create & push manifest (latest)
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
-t calciumion/new-api:latest \
|
-t calciumion/new-api:latest \
|
||||||
calciumion/new-api:latest-amd64 \
|
calciumion/new-api:latest-amd64 \
|
||||||
calciumion/new-api:latest-arm64
|
calciumion/new-api:latest-arm64
|
||||||
|
|
||||||
- name: Output manifest digest
|
- name: Manifest summary
|
||||||
run: |
|
run: |
|
||||||
echo "### Multi-arch Manifest" >> $GITHUB_STEP_SUMMARY
|
echo "### Multi-arch Manifest" >> $GITHUB_STEP_SUMMARY
|
||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
docker buildx imagetools inspect calciumion/new-api:${TAG} >> $GITHUB_STEP_SUMMARY
|
docker buildx imagetools inspect calciumion/new-api:${TAG} >> $GITHUB_STEP_SUMMARY
|
||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
# ---- GHCR ----
|
|
||||||
# - name: Log in to GHCR
|
|
||||||
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
|
||||||
# with:
|
|
||||||
# registry: ghcr.io
|
|
||||||
# username: ${{ github.actor }}
|
|
||||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
# - name: Create & push manifest (GHCR - version)
|
|
||||||
# run: |
|
|
||||||
# docker buildx imagetools create \
|
|
||||||
# -t ghcr.io/${GHCR_REPOSITORY}:${TAG} \
|
|
||||||
# ghcr.io/${GHCR_REPOSITORY}:${TAG}-amd64 \
|
|
||||||
# ghcr.io/${GHCR_REPOSITORY}:${TAG}-arm64
|
|
||||||
#
|
|
||||||
# - name: Create & push manifest (GHCR - latest)
|
|
||||||
# run: |
|
|
||||||
# docker buildx imagetools create \
|
|
||||||
# -t ghcr.io/${GHCR_REPOSITORY}:latest \
|
|
||||||
# ghcr.io/${GHCR_REPOSITORY}:latest-amd64 \
|
|
||||||
# ghcr.io/${GHCR_REPOSITORY}:latest-arm64
|
|
||||||
@@ -29,14 +29,22 @@ jobs:
|
|||||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
- name: Build Frontend
|
- name: Build Frontend (default)
|
||||||
env:
|
env:
|
||||||
CI: ""
|
CI: ""
|
||||||
run: |
|
run: |
|
||||||
cd web
|
cd web/default
|
||||||
bun install
|
bun install
|
||||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
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
|
- name: Set up Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
with:
|
with:
|
||||||
@@ -78,15 +86,23 @@ jobs:
|
|||||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
- name: Build Frontend
|
- name: Build Frontend (default)
|
||||||
env:
|
env:
|
||||||
CI: ""
|
CI: ""
|
||||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||||
run: |
|
run: |
|
||||||
cd web
|
cd web/default
|
||||||
bun install
|
bun install
|
||||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
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
|
- name: Set up Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
with:
|
with:
|
||||||
@@ -126,14 +142,22 @@ jobs:
|
|||||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
- name: Build Frontend
|
- name: Build Frontend (default)
|
||||||
env:
|
env:
|
||||||
CI: ""
|
CI: ""
|
||||||
run: |
|
run: |
|
||||||
cd web
|
cd web/default
|
||||||
bun install
|
bun install
|
||||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
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
|
- name: Set up Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ build
|
|||||||
*.db-journal
|
*.db-journal
|
||||||
logs
|
logs
|
||||||
web/dist
|
web/dist
|
||||||
|
web/node_modules
|
||||||
|
web/default/dist
|
||||||
|
web/classic/dist
|
||||||
.env
|
.env
|
||||||
one-api
|
one-api
|
||||||
new-api
|
new-api
|
||||||
@@ -19,9 +22,9 @@ tiktoken_cache
|
|||||||
.gocache
|
.gocache
|
||||||
.gomodcache/
|
.gomodcache/
|
||||||
.cache
|
.cache
|
||||||
web/bun.lock
|
|
||||||
plans
|
plans
|
||||||
.claude
|
.claude
|
||||||
|
.cursor
|
||||||
|
|
||||||
electron/node_modules
|
electron/node_modules
|
||||||
electron/dist
|
electron/dist
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Go 1.22+, Gin web framework, GORM v2 ORM
|
- **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, Radix UI, Tailwind CSS
|
||||||
- **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
|
- **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
|
||||||
- **Cache**: Redis (go-redis) + in-memory cache
|
- **Cache**: Redis (go-redis) + in-memory cache
|
||||||
- **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.)
|
- **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)
|
i18n/ — Backend internationalization (go-i18n, en/zh)
|
||||||
oauth/ — OAuth provider implementations
|
oauth/ — OAuth provider implementations
|
||||||
pkg/ — Internal packages (cachex, ionet)
|
pkg/ — Internal packages (cachex, ionet)
|
||||||
web/ — React frontend
|
web/ — Frontend themes container
|
||||||
web/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
|
web/default/ — Default frontend (React 19, Rsbuild, Radix 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)
|
## Internationalization (i18n)
|
||||||
@@ -43,13 +45,12 @@ web/ — React frontend
|
|||||||
- Library: `nicksnyder/go-i18n/v2`
|
- Library: `nicksnyder/go-i18n/v2`
|
||||||
- Languages: en, zh
|
- Languages: en, zh
|
||||||
|
|
||||||
### Frontend (`web/src/i18n/`)
|
### Frontend (`web/default/src/i18n/`)
|
||||||
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
|
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
|
||||||
- Languages: zh (fallback), en, fr, ru, ja, vi
|
- Languages: en (base), zh (fallback), fr, ru, ja, vi
|
||||||
- Translation files: `web/src/i18n/locales/{lang}.json` — flat JSON, keys are Chinese source strings
|
- Translation files: `web/default/src/i18n/locales/{lang}.json` — flat JSON, keys are English source strings
|
||||||
- Usage: `useTranslation()` hook, call `t('中文key')` in components
|
- Usage: `useTranslation()` hook, call `t('English key')` in components
|
||||||
- Semi UI locale synced via `SemiLocaleWrapper`
|
- CLI tools: `bun run i18n:sync` (from `web/default/`)
|
||||||
- CLI tools: `bun run i18n:extract`, `bun run i18n:sync`, `bun run i18n:lint`
|
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ All database code MUST be fully compatible with all three databases simultaneous
|
|||||||
|
|
||||||
### Rule 3: Frontend — Prefer Bun
|
### 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 install` for dependency installation
|
||||||
- `bun run dev` for development server
|
- `bun run dev` for development server
|
||||||
- `bun run build` for production build
|
- `bun run build` for production build
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Go 1.22+, Gin web framework, GORM v2 ORM
|
- **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, Radix UI, Tailwind CSS
|
||||||
- **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
|
- **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
|
||||||
- **Cache**: Redis (go-redis) + in-memory cache
|
- **Cache**: Redis (go-redis) + in-memory cache
|
||||||
- **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.)
|
- **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)
|
i18n/ — Backend internationalization (go-i18n, en/zh)
|
||||||
oauth/ — OAuth provider implementations
|
oauth/ — OAuth provider implementations
|
||||||
pkg/ — Internal packages (cachex, ionet)
|
pkg/ — Internal packages (cachex, ionet)
|
||||||
web/ — React frontend
|
web/ — Frontend themes container
|
||||||
web/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
|
web/default/ — Default frontend (React 19, Rsbuild, Radix 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)
|
## Internationalization (i18n)
|
||||||
@@ -43,13 +45,12 @@ web/ — React frontend
|
|||||||
- Library: `nicksnyder/go-i18n/v2`
|
- Library: `nicksnyder/go-i18n/v2`
|
||||||
- Languages: en, zh
|
- Languages: en, zh
|
||||||
|
|
||||||
### Frontend (`web/src/i18n/`)
|
### Frontend (`web/default/src/i18n/`)
|
||||||
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
|
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
|
||||||
- Languages: zh (fallback), en, fr, ru, ja, vi
|
- Languages: en (base), zh (fallback), fr, ru, ja, vi
|
||||||
- Translation files: `web/src/i18n/locales/{lang}.json` — flat JSON, keys are Chinese source strings
|
- Translation files: `web/default/src/i18n/locales/{lang}.json` — flat JSON, keys are English source strings
|
||||||
- Usage: `useTranslation()` hook, call `t('中文key')` in components
|
- Usage: `useTranslation()` hook, call `t('English key')` in components
|
||||||
- Semi UI locale synced via `SemiLocaleWrapper`
|
- CLI tools: `bun run i18n:sync` (from `web/default/`)
|
||||||
- CLI tools: `bun run i18n:extract`, `bun run i18n:sync`, `bun run i18n:lint`
|
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ All database code MUST be fully compatible with all three databases simultaneous
|
|||||||
|
|
||||||
### Rule 3: Frontend — Prefer Bun
|
### 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 install` for dependency installation
|
||||||
- `bun run dev` for development server
|
- `bun run dev` for development server
|
||||||
- `bun run build` for production build
|
- `bun run build` for production build
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Customization: subscription-capable redemption codes
|
||||||
|
|
||||||
|
Base upstream commit: 677d02f2
|
||||||
|
Local branch: codex/redeem-subscription
|
||||||
|
Deployment dir: /root/docker/newapi
|
||||||
|
|
||||||
|
What changed
|
||||||
|
- Redemption codes support two modes: quota and subscription.
|
||||||
|
- Subscription redemption creates user_subscriptions with source=redeem.
|
||||||
|
- Admin redemption UI supports selecting a subscription plan.
|
||||||
|
- User redemption response distinguishes quota vs subscription.
|
||||||
|
|
||||||
|
Suggested future upgrade flow
|
||||||
|
1. Pull latest upstream source into a clean clone.
|
||||||
|
2. Re-apply the patch file in this folder.
|
||||||
|
3. Resolve any merge conflicts in redemption/subscription files.
|
||||||
|
4. Rebuild with: docker compose up -d --build new-api
|
||||||
|
5. Verify with: curl http://127.0.0.1:3000/api/status
|
||||||
|
|
||||||
|
Current compose note
|
||||||
|
- This server is currently using source build mode instead of the official image.
|
||||||
|
- Original compose backup: docker-compose.yml.bak-20260406094555
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
|
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY web/package.json .
|
COPY web/default/package.json .
|
||||||
COPY web/bun.lock .
|
COPY web/default/bun.lock .
|
||||||
RUN bun install
|
RUN bun install
|
||||||
COPY ./web .
|
COPY ./web/default .
|
||||||
COPY ./VERSION .
|
COPY ./VERSION .
|
||||||
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
||||||
|
|
||||||
|
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
|
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
|
||||||
ENV GO111MODULE=on CGO_ENABLED=0
|
ENV GO111MODULE=on CGO_ENABLED=0
|
||||||
|
|
||||||
@@ -22,7 +32,8 @@ ADD go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
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
|
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||||
|
|
||||||
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 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 go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& update-ca-certificates
|
||||||
|
|
||||||
|
COPY --from=builder /build/new-api /
|
||||||
|
EXPOSE 3000
|
||||||
|
WORKDIR /data
|
||||||
|
ENTRYPOINT ["/new-api"]
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& update-ca-certificates
|
||||||
|
|
||||||
|
COPY new-api /
|
||||||
|
EXPOSE 3000
|
||||||
|
WORKDIR /data
|
||||||
|
ENTRYPOINT ["/new-api"]
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# 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) | [](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">
|
||||||
|
|
||||||
|
[](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,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# New API
|
# New API
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# New API
|
# New API
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# New API
|
# New API
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# New API
|
# New API
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# New API
|
# New API
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
//"os"
|
//"os"
|
||||||
//"strconv"
|
//"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -17,6 +18,24 @@ var Footer = ""
|
|||||||
var Logo = ""
|
var Logo = ""
|
||||||
var TopUpLink = ""
|
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 ChatLink = ""
|
||||||
// var ChatLink2 = ""
|
// var ChatLink2 = ""
|
||||||
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
|
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
|
||||||
|
|||||||
@@ -41,3 +41,29 @@ func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
|
|||||||
FileSystem: http.FS(efs),
|
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}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ func GetStatus(c *gin.Context) {
|
|||||||
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
|
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
|
||||||
"telegram_oauth": common.TelegramOAuthEnabled,
|
"telegram_oauth": common.TelegramOAuthEnabled,
|
||||||
"telegram_bot_name": common.TelegramBotName,
|
"telegram_bot_name": common.TelegramBotName,
|
||||||
|
"theme": system_setting.GetThemeSettings().Frontend,
|
||||||
"system_name": common.SystemName,
|
"system_name": common.SystemName,
|
||||||
"logo": common.Logo,
|
"logo": common.Logo,
|
||||||
"footer_html": common.Footer,
|
"footer_html": common.Footer,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -198,6 +198,14 @@ func UpdateOption(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
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":
|
case "GroupRatio":
|
||||||
err = ratio_setting.CheckGroupRatio(option.Value.(string))
|
err = ratio_setting.CheckGroupRatio(option.Value.(string))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
@@ -19,10 +20,10 @@ func GetAllRedemptions(c *gin.Context) {
|
|||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
enrichRedemptions(redemptions)
|
||||||
pageInfo.SetTotal(int(total))
|
pageInfo.SetTotal(int(total))
|
||||||
pageInfo.SetItems(redemptions)
|
pageInfo.SetItems(redemptions)
|
||||||
common.ApiSuccess(c, pageInfo)
|
common.ApiSuccess(c, pageInfo)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func SearchRedemptions(c *gin.Context) {
|
func SearchRedemptions(c *gin.Context) {
|
||||||
@@ -33,10 +34,10 @@ func SearchRedemptions(c *gin.Context) {
|
|||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
enrichRedemptions(redemptions)
|
||||||
pageInfo.SetTotal(int(total))
|
pageInfo.SetTotal(int(total))
|
||||||
pageInfo.SetItems(redemptions)
|
pageInfo.SetItems(redemptions)
|
||||||
common.ApiSuccess(c, pageInfo)
|
common.ApiSuccess(c, pageInfo)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRedemption(c *gin.Context) {
|
func GetRedemption(c *gin.Context) {
|
||||||
@@ -50,12 +51,12 @@ func GetRedemption(c *gin.Context) {
|
|||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
enrichRedemption(redemption)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
"data": redemption,
|
"data": redemption,
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddRedemption(c *gin.Context) {
|
func AddRedemption(c *gin.Context) {
|
||||||
@@ -65,8 +66,7 @@ func AddRedemption(c *gin.Context) {
|
|||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 {
|
if !validateRedemptionPayload(c, &redemption, true) {
|
||||||
common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if redemption.Count <= 0 {
|
if redemption.Count <= 0 {
|
||||||
@@ -77,10 +77,6 @@ func AddRedemption(c *gin.Context) {
|
|||||||
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
|
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var keys []string
|
var keys []string
|
||||||
for i := 0; i < redemption.Count; i++ {
|
for i := 0; i < redemption.Count; i++ {
|
||||||
key := common.GetUUID()
|
key := common.GetUUID()
|
||||||
@@ -90,6 +86,9 @@ func AddRedemption(c *gin.Context) {
|
|||||||
Key: key,
|
Key: key,
|
||||||
CreatedTime: common.GetTimestamp(),
|
CreatedTime: common.GetTimestamp(),
|
||||||
Quota: redemption.Quota,
|
Quota: redemption.Quota,
|
||||||
|
RedeemType: redemption.RedeemType,
|
||||||
|
PlanId: redemption.PlanId,
|
||||||
|
SourceNote: redemption.SourceNote,
|
||||||
ExpiredTime: redemption.ExpiredTime,
|
ExpiredTime: redemption.ExpiredTime,
|
||||||
}
|
}
|
||||||
err = cleanRedemption.Insert()
|
err = cleanRedemption.Insert()
|
||||||
@@ -109,7 +108,6 @@ func AddRedemption(c *gin.Context) {
|
|||||||
"message": "",
|
"message": "",
|
||||||
"data": keys,
|
"data": keys,
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteRedemption(c *gin.Context) {
|
func DeleteRedemption(c *gin.Context) {
|
||||||
@@ -123,7 +121,6 @@ func DeleteRedemption(c *gin.Context) {
|
|||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateRedemption(c *gin.Context) {
|
func UpdateRedemption(c *gin.Context) {
|
||||||
@@ -140,13 +137,14 @@ func UpdateRedemption(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if statusOnly == "" {
|
if statusOnly == "" {
|
||||||
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
if !validateRedemptionPayload(c, &redemption, true) {
|
||||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// If you add more fields, please also update redemption.Update()
|
|
||||||
cleanRedemption.Name = redemption.Name
|
cleanRedemption.Name = redemption.Name
|
||||||
cleanRedemption.Quota = redemption.Quota
|
cleanRedemption.Quota = redemption.Quota
|
||||||
|
cleanRedemption.RedeemType = redemption.RedeemType
|
||||||
|
cleanRedemption.PlanId = redemption.PlanId
|
||||||
|
cleanRedemption.SourceNote = redemption.SourceNote
|
||||||
cleanRedemption.ExpiredTime = redemption.ExpiredTime
|
cleanRedemption.ExpiredTime = redemption.ExpiredTime
|
||||||
}
|
}
|
||||||
if statusOnly != "" {
|
if statusOnly != "" {
|
||||||
@@ -157,12 +155,12 @@ func UpdateRedemption(c *gin.Context) {
|
|||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
enrichRedemption(cleanRedemption)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
"data": cleanRedemption,
|
"data": cleanRedemption,
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteInvalidRedemption(c *gin.Context) {
|
func DeleteInvalidRedemption(c *gin.Context) {
|
||||||
@@ -176,7 +174,6 @@ func DeleteInvalidRedemption(c *gin.Context) {
|
|||||||
"message": "",
|
"message": "",
|
||||||
"data": rows,
|
"data": rows,
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
|
func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
|
||||||
@@ -185,3 +182,78 @@ func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
|
|||||||
}
|
}
|
||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateRedemptionPayload(c *gin.Context, redemption *model.Redemption, requireName bool) bool {
|
||||||
|
if redemption == nil {
|
||||||
|
common.ApiErrorMsg(c, "invalid redemption payload")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
redemption.Name = strings.TrimSpace(redemption.Name)
|
||||||
|
redemption.SourceNote = strings.TrimSpace(redemption.SourceNote)
|
||||||
|
redemption.RedeemType = model.NormalizeRedemptionType(redemption.RedeemType)
|
||||||
|
if redemption.RedeemType == "" {
|
||||||
|
common.ApiErrorMsg(c, "invalid redeem type")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if requireName && (utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20) {
|
||||||
|
common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch redemption.RedeemType {
|
||||||
|
case model.RedemptionTypeQuota:
|
||||||
|
if redemption.Quota <= 0 {
|
||||||
|
common.ApiErrorMsg(c, "quota must be greater than 0")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
redemption.PlanId = 0
|
||||||
|
case model.RedemptionTypeSubscription:
|
||||||
|
if redemption.PlanId <= 0 {
|
||||||
|
common.ApiErrorMsg(c, "plan_id is required")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
|
||||||
|
if err != nil || plan == nil {
|
||||||
|
common.ApiErrorMsg(c, "subscription plan not found")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
redemption.Quota = 0
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrichRedemptions(redemptions []*model.Redemption) {
|
||||||
|
if len(redemptions) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
planTitles := make(map[int]string)
|
||||||
|
for _, redemption := range redemptions {
|
||||||
|
if redemption == nil || model.NormalizeRedemptionType(redemption.RedeemType) != model.RedemptionTypeSubscription || redemption.PlanId <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := planTitles[redemption.PlanId]; ok {
|
||||||
|
redemption.PlanTitle = planTitles[redemption.PlanId]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
|
||||||
|
if err != nil || plan == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
planTitles[redemption.PlanId] = plan.Title
|
||||||
|
redemption.PlanTitle = plan.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrichRedemption(redemption *model.Redemption) {
|
||||||
|
if redemption == nil || model.NormalizeRedemptionType(redemption.RedeemType) != model.RedemptionTypeSubscription || redemption.PlanId <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
|
||||||
|
if err != nil || plan == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redemption.PlanTitle = plan.Title
|
||||||
|
}
|
||||||
|
|||||||
@@ -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] + "..."
|
||||||
|
}
|
||||||
@@ -91,6 +91,7 @@ func Login(c *gin.Context) {
|
|||||||
|
|
||||||
// setup session & cookies and then return user info
|
// setup session & cookies and then return user info
|
||||||
func setupLogin(user *model.User, c *gin.Context) {
|
func setupLogin(user *model.User, c *gin.Context) {
|
||||||
|
model.UpdateUserLastLoginAt(user.Id)
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
session.Set("id", user.Id)
|
session.Set("id", user.Id)
|
||||||
session.Set("username", user.Username)
|
session.Set("username", user.Username)
|
||||||
@@ -1093,7 +1094,7 @@ func TopUp(c *gin.Context) {
|
|||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
quota, err := model.Redeem(req.Key, id)
|
result, err := model.Redeem(req.Key, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrRedeemFailed) {
|
if errors.Is(err, model.ErrRedeemFailed) {
|
||||||
common.ApiErrorI18n(c, i18n.MsgRedeemFailed)
|
common.ApiErrorI18n(c, i18n.MsgRedeemFailed)
|
||||||
@@ -1105,7 +1106,7 @@ func TopUp(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
"data": quota,
|
"data": result,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
new-api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: new-api-local:test
|
||||||
|
container_name: new-api
|
||||||
|
restart: always
|
||||||
|
command: --log-dir /app/logs
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "${NEWAPI_PORT}:3000"
|
||||||
|
volumes:
|
||||||
|
- /root/docker/newapi/data:/data
|
||||||
|
- /root/docker/newapi/logs:/app/logs
|
||||||
|
environment:
|
||||||
|
- SQL_DSN=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||||
|
- REDIS_CONN_STRING=${REDIS_CONN_STRING}
|
||||||
|
- TZ=${TZ}
|
||||||
|
- ERROR_LOG_ENABLED=${ERROR_LOG_ENABLED}
|
||||||
|
- BATCH_UPDATE_ENABLED=${BATCH_UPDATE_ENABLED}
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
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
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
container_name: redis
|
||||||
|
restart: always
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# New API 升级摘要
|
||||||
|
|
||||||
|
本文档记录当前分支从自定义 `v0.12.1` 基线升级到上游 `v0.12.12` 的合并结果、保留的定制内容、关键变化与验证结论。
|
||||||
|
|
||||||
|
## 版本信息
|
||||||
|
|
||||||
|
- 上游最新正式版本:`v0.12.12`
|
||||||
|
- 上游 tag 提交:`28347402`
|
||||||
|
- 合并提交:`06f9bde3` `Merge tag 'v0.12.12' into codex/redeem-subscription`
|
||||||
|
- 兼容性修复提交:`198e723b` `fix: restore Claude file conversion and preserve stream status`
|
||||||
|
- 远端分支:`lich/codex/redeem-subscription`
|
||||||
|
|
||||||
|
## 升级背景
|
||||||
|
|
||||||
|
当前代码最初基于上游 `v0.12.1` 开发,并在此基础上加入了订阅兑换码、订阅展示与部署脚本等自定义修改。
|
||||||
|
|
||||||
|
本次升级的目标是:
|
||||||
|
|
||||||
|
- 合入上游 `v0.12.12` 全量改动
|
||||||
|
- 保留现有的订阅兑换码与订阅相关定制功能
|
||||||
|
- 吸收上游在 `v0.12.10` 引入的 Stripe webhook 安全修复
|
||||||
|
- 让后端测试、前端构建恢复到可用状态
|
||||||
|
|
||||||
|
## 保留的定制功能
|
||||||
|
|
||||||
|
本次升级中保留了当前分支已有的自定义能力:
|
||||||
|
|
||||||
|
- 订阅型兑换码
|
||||||
|
- 兑换码开通订阅后的用户分组升级逻辑
|
||||||
|
- 订阅相关前端展示与文案优化
|
||||||
|
- 已购买订阅额度重置行为修复
|
||||||
|
- 自定义部署文件和远端部署脚本
|
||||||
|
|
||||||
|
## 上游关键变化
|
||||||
|
|
||||||
|
从 `v0.12.1` 升级到 `v0.12.12`,上游中比较重要的变化包括:
|
||||||
|
|
||||||
|
### 支付与安全
|
||||||
|
|
||||||
|
- `v0.12.10` 引入 Stripe webhook 异步支付处理,避免未真正支付成功就提前入账
|
||||||
|
- `TopUp` 增加 `PaymentMethod` 字段,并增强支付方式校验
|
||||||
|
- 登录与 Token 鉴权错误处理增强,减少信息泄露风险
|
||||||
|
|
||||||
|
### 订阅与钱包
|
||||||
|
|
||||||
|
- 订阅卡片支持显示下次额度重置时间
|
||||||
|
- 多处额度输入改为金额优先的交互
|
||||||
|
- 用户与管理员额度调整体验增强
|
||||||
|
|
||||||
|
### 模型与 Relay
|
||||||
|
|
||||||
|
- Claude 增加 `cache_control`、`speed` 透传控制
|
||||||
|
- Claude 空字符串消息处理修复
|
||||||
|
- Gemini、Azure 等渠道兼容性修复
|
||||||
|
- 新增 `claude-opus-4-7` 支持
|
||||||
|
- 新增 Minimax 图片生成 relay
|
||||||
|
|
||||||
|
### 前端控制台
|
||||||
|
|
||||||
|
- Dashboard 图表增强
|
||||||
|
- 分组倍率和分组规则设置页重构
|
||||||
|
- ErrorBoundary 加入,提高页面级崩溃容错能力
|
||||||
|
- 多密钥管理弹窗索引显示修复
|
||||||
|
|
||||||
|
## 本次额外补充修复
|
||||||
|
|
||||||
|
为了恢复测试通过并兼容当前仓库行为,本次额外补充了两处修复:
|
||||||
|
|
||||||
|
### 1. Claude 文件内容转换修复
|
||||||
|
|
||||||
|
位置:`relay/channel/claude/relay-claude.go`
|
||||||
|
|
||||||
|
修复内容:
|
||||||
|
|
||||||
|
- `file` 类型内容会结合文件名推断 MIME
|
||||||
|
- 文本文件会解码为 Claude `text`
|
||||||
|
- PDF 文件会转换为 Claude `document`
|
||||||
|
- 未知二进制文件会忽略,不再误判为图片
|
||||||
|
|
||||||
|
### 2. StreamStatus 保留修复
|
||||||
|
|
||||||
|
位置:`relay/helper/stream_scanner.go`
|
||||||
|
|
||||||
|
修复内容:
|
||||||
|
|
||||||
|
- `StreamScannerHandler` 不再无条件覆盖调用方预置的 `StreamStatus`
|
||||||
|
- 预初始化的错误计数与状态可以被正确保留
|
||||||
|
|
||||||
|
## 冲突处理说明
|
||||||
|
|
||||||
|
本次合并过程中,主要人工处理了以下冲突:
|
||||||
|
|
||||||
|
- `model/redemption.go`
|
||||||
|
- 保留订阅兑换码逻辑
|
||||||
|
- 对齐上游错误定义拆分
|
||||||
|
- `web/src/components/table/redemptions/modals/EditRedemptionModal.jsx`
|
||||||
|
- 保留订阅兑换码创建能力
|
||||||
|
- 合入上游金额优先输入 UI
|
||||||
|
- `web/package.json`
|
||||||
|
- 保留现有 `antd`
|
||||||
|
- 采用上游升级后的 `axios 1.15.0`
|
||||||
|
|
||||||
|
## 验证结果
|
||||||
|
|
||||||
|
本次升级后已完成如下验证:
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
- `go build ./...` 通过
|
||||||
|
- `go test ./...` 通过
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
- `bun install` 通过
|
||||||
|
- `bun run build` 通过
|
||||||
|
|
||||||
|
### 已确认吸收的安全修复
|
||||||
|
|
||||||
|
- Stripe webhook 安全修复已经随着上游 `v0.12.10+` 合入
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
当前分支已经完成:
|
||||||
|
|
||||||
|
- 合并上游 `v0.12.12`
|
||||||
|
- 修复合并后失败测试
|
||||||
|
- 后端测试全绿
|
||||||
|
- 前端构建通过
|
||||||
|
- 已推送到远端分支 `lich/codex/redeem-subscription`
|
||||||
|
|
||||||
|
## 建议后续动作
|
||||||
|
|
||||||
|
- 在实际部署环境做一轮支付、订阅、兑换码的业务回归
|
||||||
|
- 对 Stripe webhook、订阅购买、订阅型兑换码做线上前的重点验证
|
||||||
|
- 如需发版,可基于当前分支继续打 tag 或创建发布说明
|
||||||
@@ -34,12 +34,18 @@ import (
|
|||||||
_ "net/http/pprof"
|
_ "net/http/pprof"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed web/dist
|
//go:embed web/default/dist
|
||||||
var buildFS embed.FS
|
var buildFS embed.FS
|
||||||
|
|
||||||
//go:embed web/dist/index.html
|
//go:embed web/default/dist/index.html
|
||||||
var indexPage []byte
|
var indexPage []byte
|
||||||
|
|
||||||
|
//go:embed web/classic/dist
|
||||||
|
var classicBuildFS embed.FS
|
||||||
|
|
||||||
|
//go:embed web/classic/dist/index.html
|
||||||
|
var classicIndexPage []byte
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
@@ -183,7 +189,12 @@ func main() {
|
|||||||
InjectGoogleAnalytics()
|
InjectGoogleAnalytics()
|
||||||
|
|
||||||
// 设置路由
|
// 设置路由
|
||||||
router.SetRouter(server, buildFS, indexPage)
|
router.SetRouter(server, router.ThemeAssets{
|
||||||
|
DefaultBuildFS: buildFS,
|
||||||
|
DefaultIndexPage: indexPage,
|
||||||
|
ClassicBuildFS: classicBuildFS,
|
||||||
|
ClassicIndexPage: classicIndexPage,
|
||||||
|
})
|
||||||
var port = os.Getenv("PORT")
|
var port = os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = strconv.Itoa(*common.Port)
|
port = strconv.Itoa(*common.Port)
|
||||||
@@ -213,8 +224,10 @@ func InjectUmamiAnalytics() {
|
|||||||
analyticsInjectBuilder.WriteString("\"></script>")
|
analyticsInjectBuilder.WriteString("\"></script>")
|
||||||
}
|
}
|
||||||
analyticsInjectBuilder.WriteString("<!--Umami QuantumNous-->\n")
|
analyticsInjectBuilder.WriteString("<!--Umami QuantumNous-->\n")
|
||||||
analyticsInject := analyticsInjectBuilder.String()
|
analyticsInject := []byte(analyticsInjectBuilder.String())
|
||||||
indexPage = bytes.ReplaceAll(indexPage, []byte("<!--umami-->\n"), []byte(analyticsInject))
|
placeholder := []byte("<!--umami-->\n")
|
||||||
|
indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject)
|
||||||
|
classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject)
|
||||||
}
|
}
|
||||||
|
|
||||||
func InjectGoogleAnalytics() {
|
func InjectGoogleAnalytics() {
|
||||||
@@ -235,8 +248,10 @@ func InjectGoogleAnalytics() {
|
|||||||
analyticsInjectBuilder.WriteString("</script>")
|
analyticsInjectBuilder.WriteString("</script>")
|
||||||
}
|
}
|
||||||
analyticsInjectBuilder.WriteString("<!--Google Analytics QuantumNous-->\n")
|
analyticsInjectBuilder.WriteString("<!--Google Analytics QuantumNous-->\n")
|
||||||
analyticsInject := analyticsInjectBuilder.String()
|
analyticsInject := []byte(analyticsInjectBuilder.String())
|
||||||
indexPage = bytes.ReplaceAll(indexPage, []byte("<!--Google Analytics-->\n"), []byte(analyticsInject))
|
placeholder := []byte("<!--Google Analytics-->\n")
|
||||||
|
indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject)
|
||||||
|
classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject)
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitResources() error {
|
func InitResources() error {
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
FRONTEND_DIR = ./web
|
FRONTEND_DIR = ./web/default
|
||||||
|
FRONTEND_CLASSIC_DIR = ./web/classic
|
||||||
BACKEND_DIR = .
|
BACKEND_DIR = .
|
||||||
|
|
||||||
.PHONY: all build-frontend start-backend
|
.PHONY: all build-frontend build-frontend-classic build-all-frontends start-backend dev dev-api dev-web dev-web-classic
|
||||||
|
|
||||||
all: build-frontend start-backend
|
all: build-all-frontends start-backend
|
||||||
|
|
||||||
build-frontend:
|
build-frontend:
|
||||||
@echo "Building frontend..."
|
@echo "Building default frontend..."
|
||||||
@cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
@cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||||
|
|
||||||
|
build-frontend-classic:
|
||||||
|
@echo "Building classic frontend..."
|
||||||
|
@cd $(FRONTEND_CLASSIC_DIR) && bun install && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||||
|
|
||||||
|
build-all-frontends: build-frontend build-frontend-classic
|
||||||
|
|
||||||
start-backend:
|
start-backend:
|
||||||
@echo "Starting backend dev server..."
|
@echo "Starting backend dev server..."
|
||||||
@cd $(BACKEND_DIR) && go run main.go &
|
@cd $(BACKEND_DIR) && go run main.go &
|
||||||
|
|
||||||
|
dev-api:
|
||||||
|
@echo "Starting backend services (docker)..."
|
||||||
|
@docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
dev-web:
|
||||||
|
@echo "Starting frontend dev server..."
|
||||||
|
@cd $(FRONTEND_DIR) && bun install && bun run dev
|
||||||
|
|
||||||
|
dev-web-classic:
|
||||||
|
@echo "Starting classic frontend dev server..."
|
||||||
|
@cd $(FRONTEND_CLASSIC_DIR) && bun install && bun run dev
|
||||||
|
|
||||||
|
dev: dev-api dev-web
|
||||||
|
|||||||
@@ -284,6 +284,9 @@ func migrateDB() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := migrateRedemptionRedeemType(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if common.UsingSQLite {
|
if common.UsingSQLite {
|
||||||
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
|
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -352,6 +355,9 @@ func migrateDBFast() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := migrateRedemptionRedeemType(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if common.UsingSQLite {
|
if common.UsingSQLite {
|
||||||
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
|
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -365,6 +371,18 @@ func migrateDBFast() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migrateRedemptionRedeemType() error {
|
||||||
|
if !DB.Migrator().HasTable(&Redemption{}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !DB.Migrator().HasColumn(&Redemption{}, "redeem_type") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return DB.Model(&Redemption{}).
|
||||||
|
Where("redeem_type = '' OR redeem_type IS NULL").
|
||||||
|
Update("redeem_type", RedemptionTypeQuota).Error
|
||||||
|
}
|
||||||
|
|
||||||
func migrateLOGDB() error {
|
func migrateLOGDB() error {
|
||||||
var err error
|
var err error
|
||||||
if err = LOG_DB.AutoMigrate(&Log{}); err != nil {
|
if err = LOG_DB.AutoMigrate(&Log{}); err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
"github.com/QuantumNous/new-api/logger"
|
"github.com/QuantumNous/new-api/logger"
|
||||||
@@ -11,6 +12,18 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
RedemptionTypeQuota = "quota"
|
||||||
|
RedemptionTypeSubscription = "subscription"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RedeemResult struct {
|
||||||
|
RedeemType string `json:"redeem_type"`
|
||||||
|
Quota int `json:"quota,omitempty"`
|
||||||
|
PlanId int `json:"plan_id,omitempty"`
|
||||||
|
PlanTitle string `json:"plan_title,omitempty"`
|
||||||
|
SubscriptionId int `json:"subscription_id,omitempty"`
|
||||||
|
}
|
||||||
type Redemption struct {
|
type Redemption struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
UserId int `json:"user_id"`
|
UserId int `json:"user_id"`
|
||||||
@@ -18,16 +31,30 @@ type Redemption struct {
|
|||||||
Status int `json:"status" gorm:"default:1"`
|
Status int `json:"status" gorm:"default:1"`
|
||||||
Name string `json:"name" gorm:"index"`
|
Name string `json:"name" gorm:"index"`
|
||||||
Quota int `json:"quota" gorm:"default:100"`
|
Quota int `json:"quota" gorm:"default:100"`
|
||||||
|
RedeemType string `json:"redeem_type" gorm:"type:varchar(32);not null;default:'quota'"`
|
||||||
|
PlanId int `json:"plan_id" gorm:"default:0;index"`
|
||||||
|
SourceNote string `json:"source_note" gorm:"type:varchar(255);default:''"`
|
||||||
|
PlanTitle string `json:"plan_title" gorm:"-:all"`
|
||||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||||
RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"`
|
RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"`
|
||||||
Count int `json:"count" gorm:"-:all"` // only for api request
|
Count int `json:"count" gorm:"-:all"` // only for api request
|
||||||
UsedUserId int `json:"used_user_id"`
|
UsedUserId int `json:"used_user_id"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||||
ExpiredTime int64 `json:"expired_time" gorm:"bigint"` // 过期时间,0 表示不过期
|
ExpiredTime int64 `json:"expired_time" gorm:"bigint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeRedemptionType(raw string) string {
|
||||||
|
switch strings.TrimSpace(strings.ToLower(raw)) {
|
||||||
|
case "", RedemptionTypeQuota:
|
||||||
|
return RedemptionTypeQuota
|
||||||
|
case RedemptionTypeSubscription:
|
||||||
|
return RedemptionTypeSubscription
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total int64, err error) {
|
func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total int64, err error) {
|
||||||
// 开始事务
|
|
||||||
tx := DB.Begin()
|
tx := DB.Begin()
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
return nil, 0, tx.Error
|
return nil, 0, tx.Error
|
||||||
@@ -38,21 +65,18 @@ func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 获取总数
|
|
||||||
err = tx.Model(&Redemption{}).Count(&total).Error
|
err = tx.Model(&Redemption{}).Count(&total).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取分页数据
|
|
||||||
err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error
|
err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交事务
|
|
||||||
if err = tx.Commit().Error; err != nil {
|
if err = tx.Commit().Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@@ -71,24 +95,20 @@ func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Re
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Build query based on keyword type
|
|
||||||
query := tx.Model(&Redemption{})
|
query := tx.Model(&Redemption{})
|
||||||
|
|
||||||
// Only try to convert to ID if the string represents a valid integer
|
|
||||||
if id, err := strconv.Atoi(keyword); err == nil {
|
if id, err := strconv.Atoi(keyword); err == nil {
|
||||||
query = query.Where("id = ? OR name LIKE ?", id, keyword+"%")
|
query = query.Where("id = ? OR name LIKE ?", id, keyword+"%")
|
||||||
} else {
|
} else {
|
||||||
query = query.Where("name LIKE ?", keyword+"%")
|
query = query.Where("name LIKE ?", keyword+"%")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get total count
|
|
||||||
err = query.Count(&total).Error
|
err = query.Count(&total).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get paginated data
|
|
||||||
err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error
|
err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
@@ -104,22 +124,25 @@ func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Re
|
|||||||
|
|
||||||
func GetRedemptionById(id int) (*Redemption, error) {
|
func GetRedemptionById(id int) (*Redemption, error) {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return nil, errors.New("id 为空!")
|
return nil, errors.New("id is empty")
|
||||||
}
|
}
|
||||||
redemption := Redemption{Id: id}
|
redemption := Redemption{Id: id}
|
||||||
var err error = nil
|
err := DB.First(&redemption, "id = ?", id).Error
|
||||||
err = DB.First(&redemption, "id = ?", id).Error
|
|
||||||
return &redemption, err
|
return &redemption, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func Redeem(key string, userId int) (quota int, err error) {
|
func Redeem(key string, userId int) (result *RedeemResult, err error) {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return 0, errors.New("未提供兑换码")
|
return nil, errors.New("redemption code is required")
|
||||||
}
|
}
|
||||||
if userId == 0 {
|
if userId == 0 {
|
||||||
return 0, errors.New("无效的 user id")
|
return nil, errors.New("invalid user id")
|
||||||
}
|
}
|
||||||
|
|
||||||
redemption := &Redemption{}
|
redemption := &Redemption{}
|
||||||
|
result = &RedeemResult{}
|
||||||
|
logMessage := ""
|
||||||
|
upgradeGroup := ""
|
||||||
|
|
||||||
keyCol := "`key`"
|
keyCol := "`key`"
|
||||||
if common.UsingPostgreSQL {
|
if common.UsingPostgreSQL {
|
||||||
@@ -129,18 +152,50 @@ func Redeem(key string, userId int) (quota int, err error) {
|
|||||||
err = DB.Transaction(func(tx *gorm.DB) error {
|
err = DB.Transaction(func(tx *gorm.DB) error {
|
||||||
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(keyCol+" = ?", key).First(redemption).Error
|
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(keyCol+" = ?", key).First(redemption).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("无效的兑换码")
|
return errors.New("invalid redemption code")
|
||||||
}
|
}
|
||||||
if redemption.Status != common.RedemptionCodeStatusEnabled {
|
if redemption.Status != common.RedemptionCodeStatusEnabled {
|
||||||
return errors.New("该兑换码已被使用")
|
return errors.New("redemption code is unavailable")
|
||||||
}
|
}
|
||||||
if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() {
|
if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() {
|
||||||
return errors.New("该兑换码已过期")
|
return errors.New("redemption code is expired")
|
||||||
}
|
}
|
||||||
err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
|
|
||||||
if err != nil {
|
redeemType := NormalizeRedemptionType(redemption.RedeemType)
|
||||||
return err
|
if redeemType == "" {
|
||||||
|
return errors.New("invalid redemption type")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch redeemType {
|
||||||
|
case RedemptionTypeQuota:
|
||||||
|
err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.RedeemType = RedemptionTypeQuota
|
||||||
|
result.Quota = redemption.Quota
|
||||||
|
logMessage = fmt.Sprintf("通过兑换码充值 %s,兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id)
|
||||||
|
case RedemptionTypeSubscription:
|
||||||
|
if redemption.PlanId <= 0 {
|
||||||
|
return errors.New("invalid subscription plan id")
|
||||||
|
}
|
||||||
|
plan, err := getSubscriptionPlanByIdTx(tx, redemption.PlanId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sub, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "redeem")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.RedeemType = RedemptionTypeSubscription
|
||||||
|
result.PlanId = plan.Id
|
||||||
|
result.PlanTitle = plan.Title
|
||||||
|
result.SubscriptionId = sub.Id
|
||||||
|
upgradeGroup = strings.TrimSpace(plan.UpgradeGroup)
|
||||||
|
logMessage = fmt.Sprintf("通过兑换码开通订阅套餐 %s,兑换码ID %d", plan.Title, redemption.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
redemption.RedeemType = redeemType
|
||||||
redemption.RedeemedTime = common.GetTimestamp()
|
redemption.RedeemedTime = common.GetTimestamp()
|
||||||
redemption.Status = common.RedemptionCodeStatusUsed
|
redemption.Status = common.RedemptionCodeStatusUsed
|
||||||
redemption.UsedUserId = userId
|
redemption.UsedUserId = userId
|
||||||
@@ -149,39 +204,42 @@ func Redeem(key string, userId int) (quota int, err error) {
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.SysError("redemption failed: " + err.Error())
|
common.SysError("redemption failed: " + err.Error())
|
||||||
return 0, ErrRedeemFailed
|
return nil, ErrRedeemFailed
|
||||||
}
|
}
|
||||||
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s,兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id))
|
|
||||||
return redemption.Quota, nil
|
if upgradeGroup != "" {
|
||||||
|
_ = UpdateUserGroupCache(userId, upgradeGroup)
|
||||||
|
}
|
||||||
|
if logMessage != "" {
|
||||||
|
RecordLog(userId, LogTypeTopup, logMessage)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (redemption *Redemption) Insert() error {
|
func (redemption *Redemption) Insert() error {
|
||||||
var err error
|
redemption.RedeemType = NormalizeRedemptionType(redemption.RedeemType)
|
||||||
err = DB.Create(redemption).Error
|
if redemption.RedeemType == "" {
|
||||||
return err
|
redemption.RedeemType = RedemptionTypeQuota
|
||||||
|
}
|
||||||
|
return DB.Create(redemption).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (redemption *Redemption) SelectUpdate() error {
|
func (redemption *Redemption) SelectUpdate() error {
|
||||||
// This can update zero values
|
|
||||||
return DB.Model(redemption).Select("redeemed_time", "status").Updates(redemption).Error
|
return DB.Model(redemption).Select("redeemed_time", "status").Updates(redemption).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Make sure your token's fields is completed, because this will update non-zero values
|
// Update Make sure your token's fields is completed, because this will update non-zero values
|
||||||
func (redemption *Redemption) Update() error {
|
func (redemption *Redemption) Update() error {
|
||||||
var err error
|
return DB.Model(redemption).Select("name", "status", "quota", "redeem_type", "plan_id", "source_note", "redeemed_time", "expired_time").Updates(redemption).Error
|
||||||
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (redemption *Redemption) Delete() error {
|
func (redemption *Redemption) Delete() error {
|
||||||
var err error
|
return DB.Delete(redemption).Error
|
||||||
err = DB.Delete(redemption).Error
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteRedemptionById(id int) (err error) {
|
func DeleteRedemptionById(id int) (err error) {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return errors.New("id 为空!")
|
return errors.New("id is empty")
|
||||||
}
|
}
|
||||||
redemption := Redemption{Id: id}
|
redemption := Redemption{Id: id}
|
||||||
err = DB.Where(redemption).First(&redemption).Error
|
err = DB.Where(redemption).First(&redemption).Error
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ type UserSubscription struct {
|
|||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
UserId int `json:"user_id" gorm:"index;index:idx_user_sub_active,priority:1"`
|
UserId int `json:"user_id" gorm:"index;index:idx_user_sub_active,priority:1"`
|
||||||
PlanId int `json:"plan_id" gorm:"index"`
|
PlanId int `json:"plan_id" gorm:"index"`
|
||||||
|
// Snapshot fields preserve the purchased plan semantics for historical subscriptions.
|
||||||
|
PlanTitle string `json:"plan_title" gorm:"type:varchar(128);default:''"`
|
||||||
|
|
||||||
AmountTotal int64 `json:"amount_total" gorm:"type:bigint;not null;default:0"`
|
AmountTotal int64 `json:"amount_total" gorm:"type:bigint;not null;default:0"`
|
||||||
AmountUsed int64 `json:"amount_used" gorm:"type:bigint;not null;default:0"`
|
AmountUsed int64 `json:"amount_used" gorm:"type:bigint;not null;default:0"`
|
||||||
@@ -247,6 +249,9 @@ type UserSubscription struct {
|
|||||||
|
|
||||||
LastResetTime int64 `json:"last_reset_time" gorm:"type:bigint;default:0"`
|
LastResetTime int64 `json:"last_reset_time" gorm:"type:bigint;default:0"`
|
||||||
NextResetTime int64 `json:"next_reset_time" gorm:"type:bigint;default:0;index"`
|
NextResetTime int64 `json:"next_reset_time" gorm:"type:bigint;default:0;index"`
|
||||||
|
// Empty QuotaResetPeriod means legacy data without a snapshot yet.
|
||||||
|
QuotaResetPeriod string `json:"quota_reset_period" gorm:"type:varchar(16);default:''"`
|
||||||
|
QuotaResetCustomSeconds int64 `json:"quota_reset_custom_seconds" gorm:"type:bigint;default:0"`
|
||||||
|
|
||||||
UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"`
|
UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"`
|
||||||
PrevUserGroup string `json:"prev_user_group" gorm:"type:varchar(64);default:''"`
|
PrevUserGroup string `json:"prev_user_group" gorm:"type:varchar(64);default:''"`
|
||||||
@@ -306,12 +311,18 @@ func NormalizeResetPeriod(period string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func calcNextResetTime(base time.Time, plan *SubscriptionPlan, endUnix int64) int64 {
|
func normalizeUserSubscriptionResetPeriod(period string) string {
|
||||||
if plan == nil {
|
switch strings.TrimSpace(period) {
|
||||||
return 0
|
case SubscriptionResetNever, SubscriptionResetDaily, SubscriptionResetWeekly, SubscriptionResetMonthly, SubscriptionResetCustom:
|
||||||
|
return strings.TrimSpace(period)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
period := NormalizeResetPeriod(plan.QuotaResetPeriod)
|
}
|
||||||
if period == SubscriptionResetNever {
|
|
||||||
|
func calcNextResetTimeByConfig(base time.Time, period string, customSeconds int64, endUnix int64) int64 {
|
||||||
|
period = normalizeUserSubscriptionResetPeriod(period)
|
||||||
|
if period == "" || period == SubscriptionResetNever {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
var next time.Time
|
var next time.Time
|
||||||
@@ -334,10 +345,10 @@ func calcNextResetTime(base time.Time, plan *SubscriptionPlan, endUnix int64) in
|
|||||||
next = time.Date(base.Year(), base.Month(), 1, 0, 0, 0, 0, base.Location()).
|
next = time.Date(base.Year(), base.Month(), 1, 0, 0, 0, 0, base.Location()).
|
||||||
AddDate(0, 1, 0)
|
AddDate(0, 1, 0)
|
||||||
case SubscriptionResetCustom:
|
case SubscriptionResetCustom:
|
||||||
if plan.QuotaResetCustomSeconds <= 0 {
|
if customSeconds <= 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
next = base.Add(time.Duration(plan.QuotaResetCustomSeconds) * time.Second)
|
next = base.Add(time.Duration(customSeconds) * time.Second)
|
||||||
default:
|
default:
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -347,6 +358,92 @@ func calcNextResetTime(base time.Time, plan *SubscriptionPlan, endUnix int64) in
|
|||||||
return next.Unix()
|
return next.Unix()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func calcNextResetTime(base time.Time, plan *SubscriptionPlan, endUnix int64) int64 {
|
||||||
|
if plan == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return calcNextResetTimeByConfig(base, NormalizeResetPeriod(plan.QuotaResetPeriod), plan.QuotaResetCustomSeconds, endUnix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferUserSubscriptionResetConfig(sub *UserSubscription) (string, int64, bool) {
|
||||||
|
if sub == nil {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
baseUnix := sub.LastResetTime
|
||||||
|
if baseUnix <= 0 {
|
||||||
|
baseUnix = sub.StartTime
|
||||||
|
}
|
||||||
|
if baseUnix <= 0 || sub.NextResetTime <= 0 || sub.NextResetTime <= baseUnix {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
base := time.Unix(baseUnix, 0)
|
||||||
|
for _, period := range []string{
|
||||||
|
SubscriptionResetDaily,
|
||||||
|
SubscriptionResetWeekly,
|
||||||
|
SubscriptionResetMonthly,
|
||||||
|
} {
|
||||||
|
if calcNextResetTimeByConfig(base, period, 0, sub.EndTime) == sub.NextResetTime {
|
||||||
|
return period, 0, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customSeconds := sub.NextResetTime - baseUnix
|
||||||
|
if customSeconds > 0 {
|
||||||
|
return SubscriptionResetCustom, customSeconds, true
|
||||||
|
}
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveUserSubscriptionResetConfig(sub *UserSubscription) (string, int64, bool) {
|
||||||
|
if sub == nil {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
period := normalizeUserSubscriptionResetPeriod(sub.QuotaResetPeriod)
|
||||||
|
if period != "" {
|
||||||
|
if period == SubscriptionResetCustom && sub.QuotaResetCustomSeconds <= 0 {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
return period, sub.QuotaResetCustomSeconds, true
|
||||||
|
}
|
||||||
|
return inferUserSubscriptionResetConfig(sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureUserSubscriptionSnapshotTx(tx *gorm.DB, sub *UserSubscription) (string, int64, error) {
|
||||||
|
if tx == nil || sub == nil {
|
||||||
|
return "", 0, errors.New("invalid snapshot args")
|
||||||
|
}
|
||||||
|
updateMap := map[string]interface{}{}
|
||||||
|
if strings.TrimSpace(sub.PlanTitle) == "" {
|
||||||
|
if plan, err := getSubscriptionPlanByIdTx(tx, sub.PlanId); err == nil && plan != nil {
|
||||||
|
sub.PlanTitle = plan.Title
|
||||||
|
if strings.TrimSpace(sub.PlanTitle) != "" {
|
||||||
|
updateMap["plan_title"] = sub.PlanTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
period, customSeconds, ok := resolveUserSubscriptionResetConfig(sub)
|
||||||
|
if ok && (normalizeUserSubscriptionResetPeriod(sub.QuotaResetPeriod) != period ||
|
||||||
|
(period == SubscriptionResetCustom && sub.QuotaResetCustomSeconds != customSeconds)) {
|
||||||
|
sub.QuotaResetPeriod = period
|
||||||
|
sub.QuotaResetCustomSeconds = customSeconds
|
||||||
|
updateMap["quota_reset_period"] = period
|
||||||
|
updateMap["quota_reset_custom_seconds"] = customSeconds
|
||||||
|
}
|
||||||
|
if len(updateMap) == 0 {
|
||||||
|
if !ok {
|
||||||
|
return "", 0, nil
|
||||||
|
}
|
||||||
|
return period, customSeconds, nil
|
||||||
|
}
|
||||||
|
updateMap["updated_at"] = common.GetTimestamp()
|
||||||
|
if err := tx.Model(&UserSubscription{}).Where("id = ?", sub.Id).Updates(updateMap).Error; err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "", 0, nil
|
||||||
|
}
|
||||||
|
return period, customSeconds, nil
|
||||||
|
}
|
||||||
|
|
||||||
func GetSubscriptionPlanById(id int) (*SubscriptionPlan, error) {
|
func GetSubscriptionPlanById(id int) (*SubscriptionPlan, error) {
|
||||||
return getSubscriptionPlanByIdTx(nil, id)
|
return getSubscriptionPlanByIdTx(nil, id)
|
||||||
}
|
}
|
||||||
@@ -453,7 +550,7 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if count >= int64(plan.MaxPurchasePerUser) {
|
if count >= int64(plan.MaxPurchasePerUser) {
|
||||||
return nil, errors.New("已达到该套餐购买上限")
|
return nil, errors.New("宸茶揪鍒拌濂楅璐拱涓婇檺")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
nowUnix := GetDBTimestamp()
|
nowUnix := GetDBTimestamp()
|
||||||
@@ -484,20 +581,23 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sub := &UserSubscription{
|
sub := &UserSubscription{
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
PlanId: plan.Id,
|
PlanId: plan.Id,
|
||||||
AmountTotal: plan.TotalAmount,
|
PlanTitle: plan.Title,
|
||||||
AmountUsed: 0,
|
AmountTotal: plan.TotalAmount,
|
||||||
StartTime: now.Unix(),
|
AmountUsed: 0,
|
||||||
EndTime: endUnix,
|
StartTime: now.Unix(),
|
||||||
Status: "active",
|
EndTime: endUnix,
|
||||||
Source: source,
|
Status: "active",
|
||||||
LastResetTime: lastReset,
|
Source: source,
|
||||||
NextResetTime: nextReset,
|
LastResetTime: lastReset,
|
||||||
UpgradeGroup: upgradeGroup,
|
NextResetTime: nextReset,
|
||||||
PrevUserGroup: prevGroup,
|
QuotaResetPeriod: NormalizeResetPeriod(plan.QuotaResetPeriod),
|
||||||
CreatedAt: common.GetTimestamp(),
|
QuotaResetCustomSeconds: plan.QuotaResetCustomSeconds,
|
||||||
UpdatedAt: common.GetTimestamp(),
|
UpgradeGroup: upgradeGroup,
|
||||||
|
PrevUserGroup: prevGroup,
|
||||||
|
CreatedAt: common.GetTimestamp(),
|
||||||
|
UpdatedAt: common.GetTimestamp(),
|
||||||
}
|
}
|
||||||
if err := tx.Create(sub).Error; err != nil {
|
if err := tx.Create(sub).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -574,7 +674,7 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedP
|
|||||||
_ = UpdateUserGroupCache(logUserId, upgradeGroup)
|
_ = UpdateUserGroupCache(logUserId, upgradeGroup)
|
||||||
}
|
}
|
||||||
if logUserId > 0 {
|
if logUserId > 0 {
|
||||||
msg := fmt.Sprintf("订阅购买成功,套餐: %s,支付金额: %.2f,支付方式: %s", logPlanTitle, logMoney, logPaymentMethod)
|
msg := fmt.Sprintf("\u8d2d\u4e70\u8ba2\u9605\u5957\u9910 %s\uff0c\u652f\u4ed8\u91d1\u989d\uff1a%.2f\uff0c\u652f\u4ed8\u65b9\u5f0f\uff1a%s", logPlanTitle, logMoney, logPaymentMethod)
|
||||||
RecordLog(logUserId, LogTypeTopup, msg)
|
RecordLog(logUserId, LogTypeTopup, msg)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -657,9 +757,14 @@ func AdminBindSubscription(userId int, planId int, sourceNote string) (string, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
logMsg := fmt.Sprintf("\u7ba1\u7406\u5458\u5f00\u901a\u8ba2\u9605\u5957\u9910 %s", plan.Title)
|
||||||
|
if strings.TrimSpace(sourceNote) != "" {
|
||||||
|
logMsg = fmt.Sprintf("%s\uff0c\u5907\u6ce8\uff1a%s", logMsg, strings.TrimSpace(sourceNote))
|
||||||
|
}
|
||||||
|
RecordLog(userId, LogTypeTopup, logMsg)
|
||||||
if strings.TrimSpace(plan.UpgradeGroup) != "" {
|
if strings.TrimSpace(plan.UpgradeGroup) != "" {
|
||||||
_ = UpdateUserGroupCache(userId, plan.UpgradeGroup)
|
_ = UpdateUserGroupCache(userId, plan.UpgradeGroup)
|
||||||
return fmt.Sprintf("用户分组将升级到 %s", plan.UpgradeGroup), nil
|
return fmt.Sprintf("鐢ㄦ埛鍒嗙粍灏嗗崌绾у埌 %s", plan.UpgradeGroup), nil
|
||||||
}
|
}
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -718,6 +823,10 @@ func buildSubscriptionSummaries(subs []UserSubscription) []SubscriptionSummary {
|
|||||||
result := make([]SubscriptionSummary, 0, len(subs))
|
result := make([]SubscriptionSummary, 0, len(subs))
|
||||||
for _, sub := range subs {
|
for _, sub := range subs {
|
||||||
subCopy := sub
|
subCopy := sub
|
||||||
|
if period, customSeconds, ok := resolveUserSubscriptionResetConfig(&subCopy); ok {
|
||||||
|
subCopy.QuotaResetPeriod = period
|
||||||
|
subCopy.QuotaResetCustomSeconds = customSeconds
|
||||||
|
}
|
||||||
result = append(result, SubscriptionSummary{
|
result = append(result, SubscriptionSummary{
|
||||||
Subscription: &subCopy,
|
Subscription: &subCopy,
|
||||||
})
|
})
|
||||||
@@ -765,7 +874,7 @@ func AdminInvalidateUserSubscription(userSubscriptionId int) (string, error) {
|
|||||||
_ = UpdateUserGroupCache(userId, cacheGroup)
|
_ = UpdateUserGroupCache(userId, cacheGroup)
|
||||||
}
|
}
|
||||||
if downgradeGroup != "" {
|
if downgradeGroup != "" {
|
||||||
return fmt.Sprintf("用户分组将回退到 %s", downgradeGroup), nil
|
return fmt.Sprintf("鐢ㄦ埛鍒嗙粍灏嗗洖閫€鍒?%s", downgradeGroup), nil
|
||||||
}
|
}
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -806,7 +915,7 @@ func AdminDeleteUserSubscription(userSubscriptionId int) (string, error) {
|
|||||||
_ = UpdateUserGroupCache(userId, cacheGroup)
|
_ = UpdateUserGroupCache(userId, cacheGroup)
|
||||||
}
|
}
|
||||||
if downgradeGroup != "" {
|
if downgradeGroup != "" {
|
||||||
return fmt.Sprintf("用户分组将回退到 %s", downgradeGroup), nil
|
return fmt.Sprintf("鐢ㄦ埛鍒嗙粍灏嗗洖閫€鍒?%s", downgradeGroup), nil
|
||||||
}
|
}
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -930,14 +1039,18 @@ func (r *SubscriptionPreConsumeRecord) BeforeUpdate(tx *gorm.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func maybeResetUserSubscriptionWithPlanTx(tx *gorm.DB, sub *UserSubscription, plan *SubscriptionPlan, now int64) error {
|
func maybeResetUserSubscriptionTx(tx *gorm.DB, sub *UserSubscription, now int64) error {
|
||||||
if tx == nil || sub == nil || plan == nil {
|
if tx == nil || sub == nil {
|
||||||
return errors.New("invalid reset args")
|
return errors.New("invalid reset args")
|
||||||
}
|
}
|
||||||
|
period, customSeconds, err := ensureUserSubscriptionSnapshotTx(tx, sub)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if sub.NextResetTime > 0 && sub.NextResetTime > now {
|
if sub.NextResetTime > 0 && sub.NextResetTime > now {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if NormalizeResetPeriod(plan.QuotaResetPeriod) == SubscriptionResetNever {
|
if period == "" || period == SubscriptionResetNever {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
baseUnix := sub.LastResetTime
|
baseUnix := sub.LastResetTime
|
||||||
@@ -945,12 +1058,12 @@ func maybeResetUserSubscriptionWithPlanTx(tx *gorm.DB, sub *UserSubscription, pl
|
|||||||
baseUnix = sub.StartTime
|
baseUnix = sub.StartTime
|
||||||
}
|
}
|
||||||
base := time.Unix(baseUnix, 0)
|
base := time.Unix(baseUnix, 0)
|
||||||
next := calcNextResetTime(base, plan, sub.EndTime)
|
next := calcNextResetTimeByConfig(base, period, customSeconds, sub.EndTime)
|
||||||
advanced := false
|
advanced := false
|
||||||
for next > 0 && next <= now {
|
for next > 0 && next <= now {
|
||||||
advanced = true
|
advanced = true
|
||||||
base = time.Unix(next, 0)
|
base = time.Unix(next, 0)
|
||||||
next = calcNextResetTime(base, plan, sub.EndTime)
|
next = calcNextResetTimeByConfig(base, period, customSeconds, sub.EndTime)
|
||||||
}
|
}
|
||||||
if !advanced {
|
if !advanced {
|
||||||
if sub.NextResetTime == 0 && next > 0 {
|
if sub.NextResetTime == 0 && next > 0 {
|
||||||
@@ -1015,11 +1128,7 @@ func PreConsumeUserSubscription(requestId string, userId int, modelName string,
|
|||||||
}
|
}
|
||||||
for _, candidate := range subs {
|
for _, candidate := range subs {
|
||||||
sub := candidate
|
sub := candidate
|
||||||
plan, err := getSubscriptionPlanByIdTx(tx, sub.PlanId)
|
if err := maybeResetUserSubscriptionTx(tx, &sub, now); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := maybeResetUserSubscriptionWithPlanTx(tx, &sub, plan, now); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
usedBefore := sub.AmountUsed
|
usedBefore := sub.AmountUsed
|
||||||
@@ -1115,18 +1224,14 @@ func ResetDueSubscriptions(limit int) (int, error) {
|
|||||||
resetCount := 0
|
resetCount := 0
|
||||||
for _, sub := range subs {
|
for _, sub := range subs {
|
||||||
subCopy := sub
|
subCopy := sub
|
||||||
plan, err := getSubscriptionPlanByIdTx(nil, sub.PlanId)
|
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||||
if err != nil || plan == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
err = DB.Transaction(func(tx *gorm.DB) error {
|
|
||||||
var locked UserSubscription
|
var locked UserSubscription
|
||||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||||
Where("id = ? AND next_reset_time > 0 AND next_reset_time <= ?", subCopy.Id, now).
|
Where("id = ? AND next_reset_time > 0 AND next_reset_time <= ?", subCopy.Id, now).
|
||||||
First(&locked).Error; err != nil {
|
First(&locked).Error; err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := maybeResetUserSubscriptionWithPlanTx(tx, &locked, plan, now); err != nil {
|
if err := maybeResetUserSubscriptionTx(tx, &locked, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
resetCount++
|
resetCount++
|
||||||
@@ -1166,6 +1271,14 @@ func GetSubscriptionPlanInfoByUserSubscriptionId(userSubscriptionId int) (*Subsc
|
|||||||
if err := DB.Where("id = ?", userSubscriptionId).First(&sub).Error; err != nil {
|
if err := DB.Where("id = ?", userSubscriptionId).First(&sub).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(sub.PlanTitle) != "" {
|
||||||
|
info := &SubscriptionPlanInfo{
|
||||||
|
PlanId: sub.PlanId,
|
||||||
|
PlanTitle: sub.PlanTitle,
|
||||||
|
}
|
||||||
|
_ = getSubscriptionPlanInfoCache().SetWithTTL(cacheKey, *info, subscriptionPlanInfoCacheTTL())
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
plan, err := getSubscriptionPlanByIdTx(nil, sub.PlanId)
|
plan, err := getSubscriptionPlanByIdTx(nil, sub.PlanId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -416,6 +416,17 @@ func (t *Task) UpdateWithStatus(fromStatus TaskStatus) (bool, error) {
|
|||||||
return result.RowsAffected > 0, nil
|
return result.RowsAffected > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskBulkUpdate performs an unconditional bulk UPDATE by upstream task_id strings.
|
||||||
|
// Same caveats as TaskBulkUpdateByID — no CAS guard.
|
||||||
|
func TaskBulkUpdate(taskIds []string, params map[string]any) error {
|
||||||
|
if len(taskIds) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return DB.Model(&Task{}).
|
||||||
|
Where("task_id in (?)", taskIds).
|
||||||
|
Updates(params).Error
|
||||||
|
}
|
||||||
|
|
||||||
// TaskBulkUpdateByID performs an unconditional bulk UPDATE by primary key IDs.
|
// TaskBulkUpdateByID performs an unconditional bulk UPDATE by primary key IDs.
|
||||||
// WARNING: This function has NO CAS (Compare-And-Swap) guard — it will overwrite
|
// WARNING: This function has NO CAS (Compare-And-Swap) guard — it will overwrite
|
||||||
// any concurrent status changes. DO NOT use in billing/quota lifecycle flows
|
// any concurrent status changes. DO NOT use in billing/quota lifecycle flows
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ type User struct {
|
|||||||
Setting string `json:"setting" gorm:"type:text;column:setting"`
|
Setting string `json:"setting" gorm:"type:text;column:setting"`
|
||||||
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
|
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
|
||||||
StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"`
|
StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"`
|
||||||
|
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
|
||||||
|
LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (user *User) ToBaseUser() *UserBase {
|
func (user *User) ToBaseUser() *UserBase {
|
||||||
@@ -951,6 +953,12 @@ func GetRootUser() (user *User) {
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func UpdateUserLastLoginAt(id int) {
|
||||||
|
if err := DB.Model(&User{}).Where("id = ?", id).Update("last_login_at", common.GetTimestamp()).Error; err != nil {
|
||||||
|
common.SysLog("failed to update user last_login_at: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func UpdateUserUsedQuotaAndRequestCount(id int, quota int) {
|
func UpdateUserUsedQuotaAndRequestCount(id int, quota int) {
|
||||||
if common.BatchUpdateEnabled {
|
if common.BatchUpdateEnabled {
|
||||||
addNewRecord(BatchUpdateTypeUsedQuota, id, quota)
|
addNewRecord(BatchUpdateTypeUsedQuota, id, quota)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package claude
|
package claude
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
"github.com/QuantumNous/new-api/constant"
|
"github.com/QuantumNous/new-api/constant"
|
||||||
@@ -35,6 +37,21 @@ func stopReasonClaude2OpenAI(reason string) string {
|
|||||||
return reasonmap.ClaudeStopReasonToOpenAIFinishReason(reason)
|
return reasonmap.ClaudeStopReasonToOpenAIFinishReason(reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inferClaudeFileMimeType(file *dto.MessageFile) string {
|
||||||
|
if file == nil || file.FileName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
dot := strings.LastIndex(file.FileName, ".")
|
||||||
|
if dot == -1 || dot+1 >= len(file.FileName) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
mimeType := service.GetMimeTypeByExtension(file.FileName[dot+1:])
|
||||||
|
if mimeType == "application/octet-stream" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return mimeType
|
||||||
|
}
|
||||||
|
|
||||||
func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) {
|
func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return
|
return
|
||||||
@@ -365,31 +382,59 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
|||||||
text = "..."
|
text = "..."
|
||||||
}
|
}
|
||||||
claudeMessage.Content = text
|
claudeMessage.Content = text
|
||||||
} else {
|
} else {
|
||||||
claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
|
claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
|
||||||
for _, mediaMessage := range message.ParseContent() {
|
for _, mediaMessage := range message.ParseContent() {
|
||||||
switch mediaMessage.Type {
|
switch mediaMessage.Type {
|
||||||
case "text":
|
case "text":
|
||||||
if mediaMessage.Text != "" {
|
if mediaMessage.Text != "" {
|
||||||
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
|
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
|
||||||
Type: "text",
|
Type: "text",
|
||||||
Text: common.GetPointer[string](mediaMessage.Text),
|
Text: common.GetPointer[string](mediaMessage.Text),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
source := mediaMessage.ToFileSource()
|
var source types.FileSource
|
||||||
if source == nil {
|
if mediaMessage.Type == dto.ContentTypeFile {
|
||||||
continue
|
file := mediaMessage.GetFile()
|
||||||
}
|
if file == nil || file.FileData == "" {
|
||||||
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude")
|
continue
|
||||||
if err != nil {
|
}
|
||||||
return nil, fmt.Errorf("get file data failed: %s", err.Error())
|
source = types.NewFileSourceFromData(file.FileData, inferClaudeFileMimeType(file))
|
||||||
}
|
} else {
|
||||||
claudeMediaMessage := dto.ClaudeMediaMessage{
|
source = mediaMessage.ToFileSource()
|
||||||
Source: &dto.ClaudeMessageSource{
|
}
|
||||||
Type: "base64",
|
if source == nil {
|
||||||
},
|
continue
|
||||||
}
|
}
|
||||||
|
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get file data failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
if mimeType == "text/plain" {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(base64Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode text file data failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
if utf8.Valid(decoded) {
|
||||||
|
text := string(decoded)
|
||||||
|
if text != "" {
|
||||||
|
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
|
||||||
|
Type: "text",
|
||||||
|
Text: common.GetPointer[string](text),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mimeType == "" || mimeType == "application/octet-stream" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
claudeMediaMessage := dto.ClaudeMediaMessage{
|
||||||
|
Source: &dto.ClaudeMessageSource{
|
||||||
|
Type: "base64",
|
||||||
|
},
|
||||||
|
}
|
||||||
if strings.HasPrefix(mimeType, "application/pdf") {
|
if strings.HasPrefix(mimeType, "application/pdf") {
|
||||||
claudeMediaMessage.Type = "document"
|
claudeMediaMessage.Type = "document"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -40,8 +40,10 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无条件新建 StreamStatus
|
// 保留调用方预先注入的 StreamStatus,避免覆盖已有错误/状态。
|
||||||
info.StreamStatus = relaycommon.NewStreamStatus()
|
if info.StreamStatus == nil {
|
||||||
|
info.StreamStatus = relaycommon.NewStreamStatus()
|
||||||
|
}
|
||||||
|
|
||||||
// 确保响应体总是被关闭
|
// 确保响应体总是被关闭
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,7 +12,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
func SetRouter(router *gin.Engine, assets ThemeAssets) {
|
||||||
SetApiRouter(router)
|
SetApiRouter(router)
|
||||||
SetDashboardRouter(router)
|
SetDashboardRouter(router)
|
||||||
SetRelayRouter(router)
|
SetRelayRouter(router)
|
||||||
@@ -24,7 +23,7 @@ func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
|||||||
common.SysLog("FRONTEND_BASE_URL is ignored on master node")
|
common.SysLog("FRONTEND_BASE_URL is ignored on master node")
|
||||||
}
|
}
|
||||||
if frontendBaseUrl == "" {
|
if frontendBaseUrl == "" {
|
||||||
SetWebRouter(router, buildFS, indexPage)
|
SetWebRouter(router, assets)
|
||||||
} else {
|
} else {
|
||||||
frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/")
|
frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/")
|
||||||
router.NoRoute(func(c *gin.Context) {
|
router.NoRoute(func(c *gin.Context) {
|
||||||
|
|||||||
@@ -13,11 +13,23 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
// ThemeAssets holds the embedded frontend assets for both themes.
|
||||||
|
type ThemeAssets struct {
|
||||||
|
DefaultBuildFS embed.FS
|
||||||
|
DefaultIndexPage []byte
|
||||||
|
ClassicBuildFS embed.FS
|
||||||
|
ClassicIndexPage []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetWebRouter(router *gin.Engine, assets ThemeAssets) {
|
||||||
|
defaultFS := common.EmbedFolder(assets.DefaultBuildFS, "web/default/dist")
|
||||||
|
classicFS := common.EmbedFolder(assets.ClassicBuildFS, "web/classic/dist")
|
||||||
|
themeFS := common.NewThemeAwareFS(defaultFS, classicFS)
|
||||||
|
|
||||||
router.Use(gzip.Gzip(gzip.DefaultCompression))
|
router.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||||
router.Use(middleware.GlobalWebRateLimit())
|
router.Use(middleware.GlobalWebRateLimit())
|
||||||
router.Use(middleware.Cache())
|
router.Use(middleware.Cache())
|
||||||
router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/dist")))
|
router.Use(static.Serve("/", themeFS))
|
||||||
router.NoRoute(func(c *gin.Context) {
|
router.NoRoute(func(c *gin.Context) {
|
||||||
c.Set(middleware.RouteTagKey, "web")
|
c.Set(middleware.RouteTagKey, "web")
|
||||||
if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") {
|
if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") {
|
||||||
@@ -25,6 +37,10 @@ func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage)
|
if common.GetTheme() == "classic" {
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.ClassicIndexPage)
|
||||||
|
} else {
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.DefaultIndexPage)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
REMOTE_HOST="${REMOTE_HOST:-hey-01}"
|
||||||
|
DEPLOY_DIR="${DEPLOY_DIR:-/root/docker/newapi-hey}"
|
||||||
|
APP_CONTAINER="${APP_CONTAINER:-new-api-hey}"
|
||||||
|
APP_PORT="${APP_PORT:-3001}"
|
||||||
|
PUBLIC_URL="${PUBLIC_URL:-https://lztoken.top}"
|
||||||
|
|
||||||
|
NODE_IMAGE="${NODE_IMAGE:-node:22-bullseye}"
|
||||||
|
GO_IMAGE="${GO_IMAGE:-golang:1.26.1-alpine}"
|
||||||
|
GOPROXY_VALUE="${GOPROXY_VALUE:-https://goproxy.cn,direct}"
|
||||||
|
FRONT_BUILD_HEAP_MB="${FRONT_BUILD_HEAP_MB:-8192}"
|
||||||
|
|
||||||
|
SYNC_EXCLUDES=(
|
||||||
|
".git"
|
||||||
|
".env"
|
||||||
|
"docker-compose.yml"
|
||||||
|
"data"
|
||||||
|
"logs"
|
||||||
|
"redis"
|
||||||
|
"backups"
|
||||||
|
".update_tmp"
|
||||||
|
"web/node_modules"
|
||||||
|
)
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
scripts/deploy-hey-01.sh
|
||||||
|
|
||||||
|
Optional environment variables:
|
||||||
|
REMOTE_HOST SSH host alias, default: hey-01
|
||||||
|
DEPLOY_DIR Remote deploy dir, default: /root/docker/newapi-hey
|
||||||
|
APP_CONTAINER Container name, default: new-api-hey
|
||||||
|
APP_PORT Local bind port on remote host, default: 3001
|
||||||
|
PUBLIC_URL Public URL for final check, default: https://lztoken.top
|
||||||
|
NODE_IMAGE Node image for frontend build, default: node:22-bullseye
|
||||||
|
GO_IMAGE Go image for backend build, default: golang:1.26.1-alpine
|
||||||
|
GOPROXY_VALUE GOPROXY for remote build, default: https://goproxy.cn,direct
|
||||||
|
FRONT_BUILD_HEAP_MB Node heap limit in MB, default: 8192
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[deploy-hey-01] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || {
|
||||||
|
printf 'Missing required command: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remote_bash() {
|
||||||
|
local script="$1"
|
||||||
|
shift
|
||||||
|
ssh "$REMOTE_HOST" 'bash -s' -- "$@" <<<"$script"
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_source() {
|
||||||
|
log "Syncing source to ${REMOTE_HOST}:${DEPLOY_DIR}"
|
||||||
|
local rsync_args=(
|
||||||
|
-az
|
||||||
|
--delete
|
||||||
|
)
|
||||||
|
local exclude
|
||||||
|
for exclude in "${SYNC_EXCLUDES[@]}"; do
|
||||||
|
rsync_args+=(--exclude "$exclude")
|
||||||
|
done
|
||||||
|
rsync "${rsync_args[@]}" "${ROOT_DIR}/" "${REMOTE_HOST}:${DEPLOY_DIR}/"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_remote_layout() {
|
||||||
|
log "Checking remote layout"
|
||||||
|
remote_bash '
|
||||||
|
set -euo pipefail
|
||||||
|
deploy_dir="$1"
|
||||||
|
test -d "$deploy_dir"
|
||||||
|
test -f "$deploy_dir/.env"
|
||||||
|
test -f "$deploy_dir/docker-compose.yml"
|
||||||
|
test -f "$deploy_dir/web/package.json"
|
||||||
|
test -f "$deploy_dir/web/package-lock.json"
|
||||||
|
test -f "$deploy_dir/web/src/helpers/render.jsx"
|
||||||
|
test -d "$deploy_dir/data"
|
||||||
|
test -d "$deploy_dir/logs"
|
||||||
|
' "$DEPLOY_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_frontend() {
|
||||||
|
log "Building frontend dist on ${REMOTE_HOST}"
|
||||||
|
remote_bash '
|
||||||
|
set -euo pipefail
|
||||||
|
deploy_dir="$1"
|
||||||
|
node_image="$2"
|
||||||
|
heap_mb="$3"
|
||||||
|
|
||||||
|
docker run --rm --network host \
|
||||||
|
-v "${deploy_dir}:/src" \
|
||||||
|
"${node_image}" \
|
||||||
|
sh -lc "
|
||||||
|
set -e
|
||||||
|
rm -rf /tmp/web
|
||||||
|
cp -a /src/web /tmp/web
|
||||||
|
cd /tmp/web
|
||||||
|
npm ci --legacy-peer-deps
|
||||||
|
NODE_OPTIONS=--max-old-space-size=${heap_mb} npm run build
|
||||||
|
rm -rf /src/web/dist
|
||||||
|
cp -a dist /src/web/dist
|
||||||
|
"
|
||||||
|
' "$DEPLOY_DIR" "$NODE_IMAGE" "$FRONT_BUILD_HEAP_MB"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_backend() {
|
||||||
|
log "Building backend binary on ${REMOTE_HOST}"
|
||||||
|
remote_bash '
|
||||||
|
set -euo pipefail
|
||||||
|
deploy_dir="$1"
|
||||||
|
go_image="$2"
|
||||||
|
goproxy_value="$3"
|
||||||
|
|
||||||
|
docker run --rm --network host \
|
||||||
|
-e "GOPROXY=${goproxy_value}" \
|
||||||
|
-v "${deploy_dir}:/src" \
|
||||||
|
-w /src \
|
||||||
|
"${go_image}" \
|
||||||
|
sh -lc "
|
||||||
|
set -e
|
||||||
|
export PATH=/usr/local/go/bin:\$PATH
|
||||||
|
VERSION_VALUE=\$(cat VERSION 2>/dev/null || true)
|
||||||
|
go mod download
|
||||||
|
go build -ldflags \"-s -w -X github.com/QuantumNous/new-api/common.Version=\${VERSION_VALUE}\" -o new-api
|
||||||
|
"
|
||||||
|
' "$DEPLOY_DIR" "$GO_IMAGE" "$GOPROXY_VALUE"
|
||||||
|
}
|
||||||
|
|
||||||
|
swap_binary() {
|
||||||
|
log "Replacing binary in ${APP_CONTAINER}"
|
||||||
|
remote_bash '
|
||||||
|
set -euo pipefail
|
||||||
|
deploy_dir="$1"
|
||||||
|
app_container="$2"
|
||||||
|
|
||||||
|
backup_dir="${deploy_dir}/backups"
|
||||||
|
mkdir -p "$backup_dir"
|
||||||
|
ts="$(date +%Y%m%d%H%M%S)"
|
||||||
|
|
||||||
|
docker inspect "$app_container" >/dev/null 2>&1
|
||||||
|
docker cp "${app_container}:/new-api" "${backup_dir}/new-api.${ts}.bak" || true
|
||||||
|
docker cp "${deploy_dir}/new-api" "${app_container}:/new-api"
|
||||||
|
docker restart "$app_container" >/dev/null
|
||||||
|
' "$DEPLOY_DIR" "$APP_CONTAINER"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_deploy() {
|
||||||
|
log "Checking local health endpoint"
|
||||||
|
remote_bash '
|
||||||
|
set -euo pipefail
|
||||||
|
app_container="$1"
|
||||||
|
app_port="$2"
|
||||||
|
public_url="$3"
|
||||||
|
|
||||||
|
local_url="http://127.0.0.1:${app_port}/api/status"
|
||||||
|
ok=0
|
||||||
|
for _ in $(seq 1 45); do
|
||||||
|
if curl -fsS "$local_url" >/dev/null 2>&1; then
|
||||||
|
ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$ok" != "1" ]]; then
|
||||||
|
echo "Health check failed: ${local_url}" >&2
|
||||||
|
docker logs --tail 120 "$app_container" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker ps --filter "name=${app_container}" --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
|
||||||
|
curl -fsS "$local_url" >/dev/null
|
||||||
|
curl -I -m 20 "$public_url" | sed -n "1,12p"
|
||||||
|
' "$APP_CONTAINER" "$APP_PORT" "$PUBLIC_URL"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_cmd ssh
|
||||||
|
require_cmd rsync
|
||||||
|
|
||||||
|
log "Starting deploy to ${REMOTE_HOST}"
|
||||||
|
sync_source
|
||||||
|
verify_remote_layout
|
||||||
|
build_frontend
|
||||||
|
build_backend
|
||||||
|
swap_binary
|
||||||
|
verify_deploy
|
||||||
|
log "Deploy completed successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
REMOTE_HOST="${REMOTE_HOST:-ubt}"
|
||||||
|
DEPLOY_DIR="${DEPLOY_DIR:-/root/docker/newapi}"
|
||||||
|
APP_CONTAINER="${APP_CONTAINER:-new-api}"
|
||||||
|
APP_PORT="${APP_PORT:-3000}"
|
||||||
|
PUBLIC_URL="${PUBLIC_URL:-https://leexx.site}"
|
||||||
|
|
||||||
|
NODE_IMAGE="${NODE_IMAGE:-node:22-bullseye}"
|
||||||
|
GO_IMAGE="${GO_IMAGE:-golang:1.26.1-alpine}"
|
||||||
|
GOPROXY_VALUE="${GOPROXY_VALUE:-https://goproxy.cn,direct}"
|
||||||
|
FRONT_BUILD_HEAP_MB="${FRONT_BUILD_HEAP_MB:-8192}"
|
||||||
|
|
||||||
|
SYNC_EXCLUDES=(
|
||||||
|
".git"
|
||||||
|
".env"
|
||||||
|
"docker-compose.yml"
|
||||||
|
"data"
|
||||||
|
"logs"
|
||||||
|
"redis"
|
||||||
|
"backups"
|
||||||
|
".update_tmp"
|
||||||
|
"web/node_modules"
|
||||||
|
)
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
scripts/deploy-ubt.sh
|
||||||
|
|
||||||
|
Optional environment variables:
|
||||||
|
REMOTE_HOST SSH host alias, default: ubt
|
||||||
|
DEPLOY_DIR Remote deploy dir, default: /root/docker/newapi
|
||||||
|
APP_CONTAINER Container name, default: new-api
|
||||||
|
APP_PORT Local bind port on remote host, default: 3000
|
||||||
|
PUBLIC_URL Public URL for final check, default: https://leexx.site
|
||||||
|
NODE_IMAGE Node image for frontend build, default: node:22-bullseye
|
||||||
|
GO_IMAGE Go image for backend build, default: golang:1.26.1-alpine
|
||||||
|
GOPROXY_VALUE GOPROXY for remote build, default: https://goproxy.cn,direct
|
||||||
|
FRONT_BUILD_HEAP_MB Node heap limit in MB, default: 8192
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[deploy-ubt] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || {
|
||||||
|
printf 'Missing required command: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remote_bash() {
|
||||||
|
local script="$1"
|
||||||
|
shift
|
||||||
|
ssh "$REMOTE_HOST" 'bash -s' -- "$@" <<<"$script"
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_source() {
|
||||||
|
log "Syncing source to ${REMOTE_HOST}:${DEPLOY_DIR}"
|
||||||
|
local rsync_args=(
|
||||||
|
-az
|
||||||
|
--delete
|
||||||
|
)
|
||||||
|
local exclude
|
||||||
|
for exclude in "${SYNC_EXCLUDES[@]}"; do
|
||||||
|
rsync_args+=(--exclude "$exclude")
|
||||||
|
done
|
||||||
|
rsync "${rsync_args[@]}" "${ROOT_DIR}/" "${REMOTE_HOST}:${DEPLOY_DIR}/"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_remote_layout() {
|
||||||
|
log "Checking remote layout"
|
||||||
|
remote_bash '
|
||||||
|
set -euo pipefail
|
||||||
|
deploy_dir="$1"
|
||||||
|
test -d "$deploy_dir"
|
||||||
|
test -f "$deploy_dir/.env"
|
||||||
|
test -f "$deploy_dir/docker-compose.yml"
|
||||||
|
test -f "$deploy_dir/web/package.json"
|
||||||
|
test -f "$deploy_dir/web/package-lock.json"
|
||||||
|
test -f "$deploy_dir/web/src/helpers/render.jsx"
|
||||||
|
test -d "$deploy_dir/data"
|
||||||
|
test -d "$deploy_dir/logs"
|
||||||
|
' "$DEPLOY_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_frontend() {
|
||||||
|
log "Building frontend dist on ${REMOTE_HOST}"
|
||||||
|
remote_bash '
|
||||||
|
set -euo pipefail
|
||||||
|
deploy_dir="$1"
|
||||||
|
node_image="$2"
|
||||||
|
heap_mb="$3"
|
||||||
|
|
||||||
|
docker run --rm --network host \
|
||||||
|
-v "${deploy_dir}:/src" \
|
||||||
|
"${node_image}" \
|
||||||
|
sh -lc "
|
||||||
|
set -e
|
||||||
|
rm -rf /tmp/web
|
||||||
|
cp -a /src/web /tmp/web
|
||||||
|
cd /tmp/web
|
||||||
|
npm ci --legacy-peer-deps
|
||||||
|
NODE_OPTIONS=--max-old-space-size=${heap_mb} npm run build
|
||||||
|
rm -rf /src/web/dist
|
||||||
|
cp -a dist /src/web/dist
|
||||||
|
"
|
||||||
|
' "$DEPLOY_DIR" "$NODE_IMAGE" "$FRONT_BUILD_HEAP_MB"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_backend() {
|
||||||
|
log "Building backend binary on ${REMOTE_HOST}"
|
||||||
|
remote_bash '
|
||||||
|
set -euo pipefail
|
||||||
|
deploy_dir="$1"
|
||||||
|
go_image="$2"
|
||||||
|
goproxy_value="$3"
|
||||||
|
|
||||||
|
docker run --rm --network host \
|
||||||
|
-e "GOPROXY=${goproxy_value}" \
|
||||||
|
-v "${deploy_dir}:/src" \
|
||||||
|
-w /src \
|
||||||
|
"${go_image}" \
|
||||||
|
sh -lc "
|
||||||
|
set -e
|
||||||
|
export PATH=/usr/local/go/bin:\$PATH
|
||||||
|
VERSION_VALUE=\$(cat VERSION 2>/dev/null || true)
|
||||||
|
go mod download
|
||||||
|
go build -ldflags \"-s -w -X github.com/QuantumNous/new-api/common.Version=\${VERSION_VALUE}\" -o new-api
|
||||||
|
"
|
||||||
|
' "$DEPLOY_DIR" "$GO_IMAGE" "$GOPROXY_VALUE"
|
||||||
|
}
|
||||||
|
|
||||||
|
swap_binary() {
|
||||||
|
log "Replacing binary in ${APP_CONTAINER}"
|
||||||
|
remote_bash '
|
||||||
|
set -euo pipefail
|
||||||
|
deploy_dir="$1"
|
||||||
|
app_container="$2"
|
||||||
|
|
||||||
|
backup_dir="${deploy_dir}/backups"
|
||||||
|
mkdir -p "$backup_dir"
|
||||||
|
ts="$(date +%Y%m%d%H%M%S)"
|
||||||
|
|
||||||
|
docker inspect "$app_container" >/dev/null 2>&1
|
||||||
|
docker cp "${app_container}:/new-api" "${backup_dir}/new-api.${ts}.bak" || true
|
||||||
|
docker cp "${deploy_dir}/new-api" "${app_container}:/new-api"
|
||||||
|
docker restart "$app_container" >/dev/null
|
||||||
|
' "$DEPLOY_DIR" "$APP_CONTAINER"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_deploy() {
|
||||||
|
log "Checking local health endpoint"
|
||||||
|
remote_bash '
|
||||||
|
set -euo pipefail
|
||||||
|
app_container="$1"
|
||||||
|
app_port="$2"
|
||||||
|
public_url="$3"
|
||||||
|
|
||||||
|
local_url="http://127.0.0.1:${app_port}/api/status"
|
||||||
|
ok=0
|
||||||
|
for _ in $(seq 1 45); do
|
||||||
|
if curl -fsS "$local_url" >/dev/null 2>&1; then
|
||||||
|
ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$ok" != "1" ]]; then
|
||||||
|
echo "Health check failed: ${local_url}" >&2
|
||||||
|
docker logs --tail 120 "$app_container" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker ps --filter "name=${app_container}" --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
|
||||||
|
curl -fsS "$local_url" >/dev/null
|
||||||
|
curl -I -m 20 "$public_url" | sed -n "1,12p"
|
||||||
|
' "$APP_CONTAINER" "$APP_PORT" "$PUBLIC_URL"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_cmd ssh
|
||||||
|
require_cmd rsync
|
||||||
|
|
||||||
|
log "Starting deploy to ${REMOTE_HOST}"
|
||||||
|
sync_source
|
||||||
|
verify_remote_layout
|
||||||
|
build_frontend
|
||||||
|
build_backend
|
||||||
|
swap_binary
|
||||||
|
verify_deploy
|
||||||
|
log "Deploy completed successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/logger"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
|
||||||
|
"github.com/bytedance/gopkg/util/gopool"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
|
||||||
|
if relayInfo.FinalPreConsumedQuota != 0 {
|
||||||
|
logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(relayInfo.FinalPreConsumedQuota)))
|
||||||
|
gopool.Go(func() {
|
||||||
|
relayInfoCopy := *relayInfo
|
||||||
|
|
||||||
|
err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false)
|
||||||
|
if err != nil {
|
||||||
|
common.SysLog("error return pre-consumed quota: " + err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreConsumeQuota checks if the user has enough quota to pre-consume.
|
||||||
|
// It returns the pre-consumed quota if successful, or an error if not.
|
||||||
|
func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
|
||||||
|
userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
|
||||||
|
if err != nil {
|
||||||
|
return types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
|
||||||
|
}
|
||||||
|
if userQuota <= 0 {
|
||||||
|
return types.NewErrorWithStatusCode(fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||||
|
}
|
||||||
|
if userQuota-preConsumedQuota < 0 {
|
||||||
|
return types.NewErrorWithStatusCode(fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||||
|
}
|
||||||
|
|
||||||
|
trustQuota := common.GetTrustQuota()
|
||||||
|
|
||||||
|
relayInfo.UserQuota = userQuota
|
||||||
|
if userQuota > trustQuota {
|
||||||
|
// 用户额度充足,判断令牌额度是否充足
|
||||||
|
if !relayInfo.TokenUnlimited {
|
||||||
|
// 非无限令牌,判断令牌额度是否充足
|
||||||
|
tokenQuota := c.GetInt("token_quota")
|
||||||
|
if tokenQuota > trustQuota {
|
||||||
|
// 令牌额度充足,信任令牌
|
||||||
|
preConsumedQuota = 0
|
||||||
|
logger.LogInfo(c, fmt.Sprintf("用户 %d 剩余额度 %s 且令牌 %d 额度 %d 充足, 信任且不需要预扣费", relayInfo.UserId, logger.FormatQuota(userQuota), relayInfo.TokenId, tokenQuota))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// in this case, we do not pre-consume quota
|
||||||
|
// because the user has enough quota
|
||||||
|
preConsumedQuota = 0
|
||||||
|
logger.LogInfo(c, fmt.Sprintf("用户 %d 额度充足且为无限额度令牌, 信任且不需要预扣费", relayInfo.UserId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if preConsumedQuota > 0 {
|
||||||
|
err := PreConsumeTokenQuota(relayInfo, preConsumedQuota)
|
||||||
|
if err != nil {
|
||||||
|
return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||||
|
}
|
||||||
|
err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota, false)
|
||||||
|
if err != nil {
|
||||||
|
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
|
||||||
|
}
|
||||||
|
logger.LogInfo(c, fmt.Sprintf("用户 %d 预扣费 %s, 预扣费后剩余额度: %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota), logger.FormatQuota(userQuota-preConsumedQuota)))
|
||||||
|
}
|
||||||
|
relayInfo.FinalPreConsumedQuota = preConsumedQuota
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -252,8 +252,16 @@ func updateConfigFromMap(config interface{}, configMap map[string]string) error
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case reflect.Map, reflect.Slice, reflect.Struct:
|
case reflect.Map:
|
||||||
// 复杂类型使用JSON反序列化
|
// json.Unmarshal merges into existing maps (keeps old keys that are
|
||||||
|
// absent from the new JSON). Allocate a fresh map so removed keys
|
||||||
|
// are properly cleared.
|
||||||
|
fresh := reflect.New(field.Type())
|
||||||
|
if err := json.Unmarshal([]byte(strValue), fresh.Interface()); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
field.Set(fresh.Elem())
|
||||||
|
case reflect.Slice, reflect.Struct:
|
||||||
err := json.Unmarshal([]byte(strValue), field.Addr().Interface())
|
err := json.Unmarshal([]byte(strValue), field.Addr().Interface())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testConfigWithMap struct {
|
||||||
|
Modes map[string]string `json:"modes"`
|
||||||
|
Exprs map[string]string `json:"exprs"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigFromMap_MapReplacement(t *testing.T) {
|
||||||
|
cfg := &testConfigWithMap{
|
||||||
|
Modes: map[string]string{
|
||||||
|
"model-a": "tiered_expr",
|
||||||
|
"model-b": "tiered_expr",
|
||||||
|
},
|
||||||
|
Exprs: map[string]string{
|
||||||
|
"model-a": "p * 5 + c * 25",
|
||||||
|
"model-b": "p * 10 + c * 50",
|
||||||
|
},
|
||||||
|
Name: "billing",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate removing model-a: new value only has model-b
|
||||||
|
err := UpdateConfigFromMap(cfg, map[string]string{
|
||||||
|
"modes": `{"model-b": "tiered_expr"}`,
|
||||||
|
"exprs": `{"model-b": "p * 10 + c * 50"}`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateConfigFromMap failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := cfg.Modes["model-a"]; ok {
|
||||||
|
t.Errorf("Modes still contains model-a after it was removed from the update; got %v", cfg.Modes)
|
||||||
|
}
|
||||||
|
if _, ok := cfg.Exprs["model-a"]; ok {
|
||||||
|
t.Errorf("Exprs still contains model-a after it was removed from the update; got %v", cfg.Exprs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Modes["model-b"] != "tiered_expr" {
|
||||||
|
t.Errorf("Modes[model-b] = %q, want %q", cfg.Modes["model-b"], "tiered_expr")
|
||||||
|
}
|
||||||
|
if cfg.Exprs["model-b"] != "p * 10 + c * 50" {
|
||||||
|
t.Errorf("Exprs[model-b] = %q, want %q", cfg.Exprs["model-b"], "p * 10 + c * 50")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigFromMap_EmptyMapClearsAll(t *testing.T) {
|
||||||
|
cfg := &testConfigWithMap{
|
||||||
|
Modes: map[string]string{
|
||||||
|
"model-a": "tiered_expr",
|
||||||
|
},
|
||||||
|
Exprs: map[string]string{
|
||||||
|
"model-a": "p * 5 + c * 25",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := UpdateConfigFromMap(cfg, map[string]string{
|
||||||
|
"modes": `{}`,
|
||||||
|
"exprs": `{}`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateConfigFromMap failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.Modes) != 0 {
|
||||||
|
t.Errorf("Modes should be empty after updating with {}, got %v", cfg.Modes)
|
||||||
|
}
|
||||||
|
if len(cfg.Exprs) != 0 {
|
||||||
|
t.Errorf("Exprs should be empty after updating with {}, got %v", cfg.Exprs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigFromMap_ScalarFieldsUnchanged(t *testing.T) {
|
||||||
|
cfg := &testConfigWithMap{
|
||||||
|
Modes: map[string]string{"m": "v"},
|
||||||
|
Name: "old",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := UpdateConfigFromMap(cfg, map[string]string{
|
||||||
|
"name": "new",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateConfigFromMap failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Name != "new" {
|
||||||
|
t.Errorf("Name = %q, want %q", cfg.Name, "new")
|
||||||
|
}
|
||||||
|
// modes was not in configMap, should remain unchanged
|
||||||
|
if cfg.Modes["m"] != "v" {
|
||||||
|
t.Errorf("Modes should be unchanged, got %v", cfg.Modes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package system_setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/setting/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ThemeSettings struct {
|
||||||
|
Frontend string `json:"frontend"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var themeSettings = ThemeSettings{
|
||||||
|
Frontend: "classic",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
config.GlobalConfig.Register("theme", &themeSettings)
|
||||||
|
syncThemeToCommon()
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncThemeToCommon() {
|
||||||
|
common.SetTheme(themeSettings.Frontend)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetThemeSettings() *ThemeSettings {
|
||||||
|
return &themeSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAndSyncTheme syncs the theme config to common after DB load.
|
||||||
|
func UpdateAndSyncTheme() {
|
||||||
|
syncThemeToCommon()
|
||||||
|
}
|
||||||
@@ -22,5 +22,4 @@ npm-debug.log*
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.idea
|
.idea
|
||||||
package-lock.json
|
yarn.lock
|
||||||
yarn.lock
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "react-template",
|
"name": "react-template",
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
"@visactor/react-vchart": "~1.8.8",
|
"@visactor/react-vchart": "~1.8.8",
|
||||||
"@visactor/vchart": "~1.8.8",
|
"@visactor/vchart": "~1.8.8",
|
||||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||||
|
"antd": "^5.23.0",
|
||||||
"axios": "1.15.0",
|
"axios": "1.15.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
@@ -1672,7 +1674,7 @@
|
|||||||
|
|
||||||
"rc-checkbox": ["rc-checkbox@3.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.25.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg=="],
|
"rc-checkbox": ["rc-checkbox@3.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.25.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg=="],
|
||||||
|
|
||||||
"rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="],
|
"rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="],
|
||||||
|
|
||||||
"rc-dialog": ["rc-dialog@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/portal": "^1.0.0-8", "classnames": "^2.2.6", "rc-motion": "^2.3.0", "rc-util": "^5.21.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg=="],
|
"rc-dialog": ["rc-dialog@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/portal": "^1.0.0-8", "classnames": "^2.2.6", "rc-motion": "^2.3.0", "rc-util": "^5.21.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg=="],
|
||||||
|
|
||||||
@@ -2142,6 +2144,8 @@
|
|||||||
|
|
||||||
"@lobehub/ui/lucide-react": ["lucide-react@0.484.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oZy8coK9kZzvqhSgfbGkPtTgyjpBvs3ukLgDPv14dSOZtBtboryWF5o8i3qen7QbGg7JhiJBz5mK1p8YoMZTLQ=="],
|
"@lobehub/ui/lucide-react": ["lucide-react@0.484.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oZy8coK9kZzvqhSgfbGkPtTgyjpBvs3ukLgDPv14dSOZtBtboryWF5o8i3qen7QbGg7JhiJBz5mK1p8YoMZTLQ=="],
|
||||||
|
|
||||||
|
"@lobehub/ui/rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="],
|
||||||
|
|
||||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="],
|
"@radix-ui/react-dismissable-layer/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="],
|
||||||
|
|
||||||
"@radix-ui/react-popper/@floating-ui/react-dom": ["@floating-ui/react-dom@0.7.2", "", { "dependencies": { "@floating-ui/dom": "^0.5.3", "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1T0sJcpHgX/u4I1OzIEhlcrvkUN8ln39nz7fMoE/2HDHrPiMFoOGR7++GYyfUmIQHkkrTinaeQsO3XWubjSvGg=="],
|
"@radix-ui/react-popper/@floating-ui/react-dom": ["@floating-ui/react-dom@0.7.2", "", { "dependencies": { "@floating-ui/dom": "^0.5.3", "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1T0sJcpHgX/u4I1OzIEhlcrvkUN8ln39nz7fMoE/2HDHrPiMFoOGR7++GYyfUmIQHkkrTinaeQsO3XWubjSvGg=="],
|
||||||
@@ -2162,8 +2166,6 @@
|
|||||||
|
|
||||||
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
|
|
||||||
"antd/rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="],
|
|
||||||
|
|
||||||
"antd/scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
|
"antd/scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
|
||||||
|
|
||||||
"cosmiconfig/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
|
"cosmiconfig/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"@visactor/react-vchart": "~1.8.8",
|
"@visactor/react-vchart": "~1.8.8",
|
||||||
"@visactor/vchart": "~1.8.8",
|
"@visactor/vchart": "~1.8.8",
|
||||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||||
|
"antd": "^5.23.0",
|
||||||
"axios": "1.15.0",
|
"axios": "1.15.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
@@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Modal } from '@douyinfe/semi-ui';
|
||||||
|
import { useSecureVerification } from '../../../hooks/common/useSecureVerification';
|
||||||
|
import { createApiCalls } from '../../../services/secureVerification';
|
||||||
|
import SecureVerificationModal from '../modals/SecureVerificationModal';
|
||||||
|
import ChannelKeyDisplay from '../ui/ChannelKeyDisplay';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渠道密钥查看组件使用示例
|
||||||
|
* 展示如何使用通用安全验证系统
|
||||||
|
*/
|
||||||
|
const ChannelKeyViewExample = ({ channelId }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [keyData, setKeyData] = useState('');
|
||||||
|
const [showKeyModal, setShowKeyModal] = useState(false);
|
||||||
|
|
||||||
|
// 使用通用安全验证 Hook
|
||||||
|
const {
|
||||||
|
isModalVisible,
|
||||||
|
verificationMethods,
|
||||||
|
verificationState,
|
||||||
|
startVerification,
|
||||||
|
executeVerification,
|
||||||
|
cancelVerification,
|
||||||
|
setVerificationCode,
|
||||||
|
switchVerificationMethod,
|
||||||
|
} = useSecureVerification({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
// 验证成功后处理结果
|
||||||
|
if (result.success && result.data?.key) {
|
||||||
|
setKeyData(result.data.key);
|
||||||
|
setShowKeyModal(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
successMessage: t('密钥获取成功'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 开始查看密钥流程
|
||||||
|
const handleViewKey = async () => {
|
||||||
|
const apiCall = createApiCalls.viewChannelKey(channelId);
|
||||||
|
|
||||||
|
await startVerification(apiCall, {
|
||||||
|
title: t('查看渠道密钥'),
|
||||||
|
description: t('为了保护账户安全,请验证您的身份。'),
|
||||||
|
preferredMethod: 'passkey', // 可以指定首选验证方式
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 查看密钥按钮 */}
|
||||||
|
<Button type='primary' theme='outline' onClick={handleViewKey}>
|
||||||
|
{t('查看密钥')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 安全验证模态框 */}
|
||||||
|
<SecureVerificationModal
|
||||||
|
visible={isModalVisible}
|
||||||
|
verificationMethods={verificationMethods}
|
||||||
|
verificationState={verificationState}
|
||||||
|
onVerify={executeVerification}
|
||||||
|
onCancel={cancelVerification}
|
||||||
|
onCodeChange={setVerificationCode}
|
||||||
|
onMethodSwitch={switchVerificationMethod}
|
||||||
|
title={verificationState.title}
|
||||||
|
description={verificationState.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 密钥显示模态框 */}
|
||||||
|
<Modal
|
||||||
|
title={t('渠道密钥信息')}
|
||||||
|
visible={showKeyModal}
|
||||||
|
onCancel={() => setShowKeyModal(false)}
|
||||||
|
footer={
|
||||||
|
<Button type='primary' onClick={() => setShowKeyModal(false)}>
|
||||||
|
{t('完成')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
width={700}
|
||||||
|
style={{ maxWidth: '90vw' }}
|
||||||
|
>
|
||||||
|
<ChannelKeyDisplay
|
||||||
|
keyData={keyData}
|
||||||
|
showSuccessIcon={true}
|
||||||
|
successText={t('密钥获取成功')}
|
||||||
|
showWarning={true}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChannelKeyViewExample;
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal, Button, Input, Typography } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可复用的两步验证模态框组件
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {boolean} props.visible - 是否显示模态框
|
||||||
|
* @param {string} props.code - 验证码值
|
||||||
|
* @param {boolean} props.loading - 是否正在验证
|
||||||
|
* @param {Function} props.onCodeChange - 验证码变化回调
|
||||||
|
* @param {Function} props.onVerify - 验证回调
|
||||||
|
* @param {Function} props.onCancel - 取消回调
|
||||||
|
* @param {string} props.title - 模态框标题
|
||||||
|
* @param {string} props.description - 验证描述文本
|
||||||
|
* @param {string} props.placeholder - 输入框占位文本
|
||||||
|
*/
|
||||||
|
const TwoFactorAuthModal = ({
|
||||||
|
visible,
|
||||||
|
code,
|
||||||
|
loading,
|
||||||
|
onCodeChange,
|
||||||
|
onVerify,
|
||||||
|
onCancel,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
placeholder,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter' && code && !loading) {
|
||||||
|
onVerify();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div className='flex items-center'>
|
||||||
|
<div className='w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3'>
|
||||||
|
<svg
|
||||||
|
className='w-4 h-4 text-blue-600 dark:text-blue-400'
|
||||||
|
fill='currentColor'
|
||||||
|
viewBox='0 0 20 20'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule='evenodd'
|
||||||
|
d='M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z'
|
||||||
|
clipRule='evenodd'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{title || t('安全验证')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
visible={visible}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={onCancel}>{t('取消')}</Button>
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
loading={loading}
|
||||||
|
disabled={!code || loading}
|
||||||
|
onClick={onVerify}
|
||||||
|
>
|
||||||
|
{t('验证')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
width={500}
|
||||||
|
style={{ maxWidth: '90vw' }}
|
||||||
|
>
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{/* 安全提示 */}
|
||||||
|
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'>
|
||||||
|
<div className='flex items-start'>
|
||||||
|
<svg
|
||||||
|
className='w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0'
|
||||||
|
fill='currentColor'
|
||||||
|
viewBox='0 0 20 20'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule='evenodd'
|
||||||
|
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
||||||
|
clipRule='evenodd'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<Typography.Text
|
||||||
|
strong
|
||||||
|
className='text-blue-800 dark:text-blue-200'
|
||||||
|
>
|
||||||
|
{t('安全验证')}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text className='block text-blue-700 dark:text-blue-300 text-sm mt-1'>
|
||||||
|
{description || t('为了保护账户安全,请验证您的两步验证码。')}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 验证码输入 */}
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong className='block mb-2'>
|
||||||
|
{t('验证身份')}
|
||||||
|
</Typography.Text>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder || t('请输入认证器验证码或备用码')}
|
||||||
|
value={code}
|
||||||
|
onChange={onCodeChange}
|
||||||
|
size='large'
|
||||||
|
maxLength={8}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
|
||||||
|
{t(
|
||||||
|
'支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。',
|
||||||
|
)}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TwoFactorAuthModal;
|
||||||