Compare commits

...

97 Commits

Author SHA1 Message Date
Seefs 1d83b5472a fix: require proper verification for passkey changes (#4393) 2026-04-22 22:55:06 +08:00
Seefs e729b22197 fix: refresh codex credentials for auto-disabled channels (#4324) 2026-04-22 22:54:52 +08:00
Seefs 5f67d2a28b fix: use stream for codex auto test (#4325) 2026-04-22 22:54:41 +08:00
Seefs d586a567e4 chore: refine codex usage modal layout (#4386)
* chore: refine codex usage modal layout

* fix: polish codex usage modal responsiveness
2026-04-22 22:54:28 +08:00
gaoren002 6afaa58d28 fix(topup): import missing Tag in recharge card (#4388) 2026-04-22 22:22:09 +08:00
Seefs f995a868e4 Merge pull request #4089 from seefs001/feature/waffo-pay
rafactor: payment
2026-04-18 14:22:54 +08:00
Seefs 5b9dcf1bda Merge pull request #4311 from KoellM/fix-gemini-3-toolconfig
fix(gemini): add IncludeServerSideToolInvocations field to ToolConfig
2026-04-18 01:13:48 +08:00
CaIon d75a046791 chore(docker-compose): set default redis password
Enable Redis requirepass in the compose template and embed the matching
credential in REDIS_CONN_STRING, aligning with the existing PostgreSQL
default password pattern so out-of-the-box deployments are not left with
an unauthenticated Redis instance.
2026-04-18 00:56:07 +08:00
CaIon 209645e26b feat(topup-log): add NODE_NAME env var for audit logs
Introduce NODE_NAME environment variable to identify node identity in top-up
audit logs, improving readability over auto-detected container internal IPs
in Docker/K8s deployments. Surface node_name in admin expanded log rows and
add it as a commented example to docker-compose.yml.
2026-04-18 00:51:04 +08:00
CaIon 6ff8c7ab03 fix(topup-log): keep row expandable and warn admins on legacy logs
Top-up logs written by pre-upgrade instances have no admin_info, which
made the expanded row empty and the row un-expandable. For admins, always
emit an entry: either the audit fields from admin_info when present, or a
warning prompting the operator to upgrade the instance so audit fields
(server IP, callback IP, payment method, system version) are recorded.
2026-04-18 00:36:05 +08:00
CaIon c31343ac76 fix(log): hide admin identity in user-visible management logs
Admin username/ID was embedded directly into the log Content for
quota changes and forced 2FA disable, leaking the operator's
identity to the target user via their own usage log page.

Move operator info into Other.admin_info so formatUserLogs strips
it for non-admin viewers, and render it in the expand panel only
for admins as "操作管理员".

Closes #4301
2026-04-18 00:16:52 +08:00
CaIon b2e62a44ee fix(topup): harden top-up search against DoS and cap user queries to 30 days
Apply the same LIKE sanitization used for token search to SearchUserTopUps
and SearchAllTopUps (reject %%, cap % count, require >=2 stripped chars,
use ESCAPE '!') and bound COUNT with a 10000-row hard limit to avoid
unbounded full-table scans.

Also restrict user-facing list and search (GetUserTopUps, SearchUserTopUps)
to records within the last 30 days via create_time. Admin endpoints
(GetAllTopUps, SearchAllTopUps) remain unrestricted.
2026-04-18 00:01:03 +08:00
CaIon 9253426223 fix(user): invalidate user and token caches when disabling user
When an admin disables/deletes/promotes/demotes a user via ManageUser,
explicitly evict the user cache and all of the user's token caches from
Redis. This prevents a disabled user from continuing to make successful
API requests until the user cache TTL expires, and ensures subsequent
requests reload fresh status from the database.
2026-04-17 23:58:45 +08:00
CaIon 209d90e861 feat(topup): add admin-only audit info to top-up logs
Thread caller IP from webhook/admin controllers through model recharge
functions and record a new RecordTopupLog entry with admin_info (server
IP, caller IP, order payment method, callback payment method, system
version). Frontend shows these fields in the expanded log row and the
IP column for admins on top-up logs, while non-admins continue to see
admin_info stripped by formatUserLogs.
2026-04-17 23:51:30 +08:00
CaIon e2807c5f95 feat: enhance SSRF protection 2026-04-17 23:46:28 +08:00
KoellM 45cc95a25c fix(gemini): add IncludeServerSideToolInvocations field to ToolConfig 2026-04-17 20:39:47 +08:00
Calcium-Ion 283474020d chore(deps): bump github.com/jackc/pgx/v5 from 5.7.1 to 5.9.0 (#4294)
Bumps [github.com/jackc/pgx/v5](https://github.com/jackc/pgx) from 5.7.1 to 5.9.0.
- [Changelog](https://github.com/jackc/pgx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jackc/pgx/compare/v5.7.1...v5.9.0)

---
updated-dependencies:
- dependency-name: github.com/jackc/pgx/v5
  dependency-version: 5.9.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-17 13:53:20 +08:00
papersnake 47d7bca268 feat: support claude-opus-4-7 (#4293)
* feat: support claude-opus-4-7

* feat: summarized display for opus 4.7
2026-04-17 13:52:34 +08:00
dependabot[bot] dd57eeb514 chore(deps): bump github.com/jackc/pgx/v5 from 5.7.1 to 5.9.0
Bumps [github.com/jackc/pgx/v5](https://github.com/jackc/pgx) from 5.7.1 to 5.9.0.
- [Changelog](https://github.com/jackc/pgx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jackc/pgx/compare/v5.7.1...v5.9.0)

---
updated-dependencies:
- dependency-name: github.com/jackc/pgx/v5
  dependency-version: 5.9.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-16 22:45:12 +00:00
CaIon 22e509c1ef refactor: simplify ShouldDisableChannel function by removing unused parameters and commented-out code 2026-04-16 20:56:44 +08:00
CaIon 3cad6b9d7f fix(claude): improve handling of empty string content in OpenAI to Claude message conversion 2026-04-16 17:44:38 +08:00
CaIon 8aaec8b1cc feat: add PaymentMethod field to TopUp model and enhance payment method validation in topup controllers 2026-04-15 21:17:49 +08:00
CaIon b2a40d3381 feat: enhance Stripe webhook handling for async payment events 2026-04-15 20:56:55 +08:00
Calcium-Ion bf130c5cde feat: include admin username in quota adjustment logs (#4216) 2026-04-15 20:56:34 +08:00
Seefs f7adf02eb4 feat(claude): add cache_control and speed passthrough controls (#4247) 2026-04-15 20:55:01 +08:00
wans10 d0c2d2c6fb fix(channel): 修复多密钥管理弹窗索引显示,将索引值调整为从1开始 (#4231) 2026-04-15 20:53:58 +08:00
power ee7cedd577 fix: use json.RawMessage for Instructions field in OpenAIResponsesResponse (#4260)
The Instructions field in OpenAIResponsesResponse was defined as string,
but upstream providers may return null or non-string JSON values for this
field. This causes json.Unmarshal to fail, resulting in HTTP 500 on
/v1/responses endpoint.

Other fields in the same struct (Status, ToolChoice, Truncation, etc.)
already use json.RawMessage. The request-side DTO (openai_request.go)
also defines Instructions as json.RawMessage. This fix aligns the
response-side with both patterns.

Co-authored-by: 40005415C\Administrator <linbin@envicool.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:51:10 +08:00
CaIon 8c8661d0d7 refactor: clean up unused imports and commented-out code in channel.go 2026-04-13 16:39:12 +08:00
feitianbubu d15e14b117 feat: include admin username in quota adjustment logs 2026-04-13 16:09:59 +08:00
woan1136 3ab65a8221 fix: add Azure channel support for /v1/responses/compact URL routing (#4149)
The Azure channel's GetRequestURL method only handled RelayModeResponses
but missed RelayModeResponsesCompact. This caused compact requests to
fall through to the generic deployments URL pattern, producing an
incorrect path that Azure returns 404 for.

This fix extends the existing responses API special handling to also
cover the compact mode, appending /compact to the subUrl when the relay
mode is ResponsesCompact.

Affected URLs (before → after):
- Normal Azure: /openai/deployments/{model}/responses/compact → /openai/v1/responses/compact
- cognitiveservices: same pattern → /openai/responses/compact
- Custom AzureResponsesVersion: properly respected for compact too

Co-authored-by: 彭俊杰 <pengjunjie@onero.com>
2026-04-13 15:23:38 +08:00
CaIon 7cfaf6c335 feat: enhance dashboard charts with improved dimension handling and ranking logic 2026-04-13 15:12:12 +08:00
MS 2bedd31b42 feat: display next quota reset time in subscription card (#4181)
Show the next quota reset time for active subscriptions in the "My Subscriptions"
section when a reset period is configured (next_reset_time > 0). Hidden when
the subscription plan has no quota reset configured.
2026-04-13 14:48:32 +08:00
萧邦 c20060931b fix(GroupTable): prevent Input cursor jumping to end on keystroke (#4208)
Refactor updateRow/addRow/removeRow to use functional setRows(prev => ...)
and ref-based onChange/duplicateNames access, making columns useMemo stable
across keystrokes so Semi UI Table does not re-mount Input components.
2026-04-13 14:41:40 +08:00
CaIon 8b22161527 fix: set TopP to nil in Claude request configuration 2026-04-13 14:36:22 +08:00
CaIon 3d0ac2d049 chore(deps): update axios 2026-04-12 23:55:07 +08:00
dependabot[bot] b81d3427ee chore(deps): bump axios from 1.13.5 to 1.15.0 in /web (#4201)
Bumps [axios](https://github.com/axios/axios) from 1.13.5 to 1.15.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.5...v1.15.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-12 23:52:04 +08:00
skynono b4df9955f4 fix: isStream status in error logs instead of hardcoded false (#4195) 2026-04-12 17:41:26 +08:00
CaIon 59c582d13c fix: harden token auth error handling to prevent info leakage
- Create model/errors.go to centralize all sentinel errors
- ValidateAccessToken now returns error to distinguish DB failures
- ValidateUserToken uses unified ErrTokenInvalid for all auth failures
  (expired/exhausted/disabled/not-found) to prevent token enumeration
- authHelper and TokenAuthReadOnly use i18n messages instead of
  hardcoded Chinese strings
- All err.Error() removed from user-facing responses; DB errors logged
  server-side and return generic "contact admin" message (HTTP 500)
- Migrate ErrRedeemFailed, ErrTwoFANotEnabled to model/errors.go
2026-04-12 17:39:00 +08:00
CaIon 2819e3a1d1 fix: improve login error handling to distinguish database errors from auth failures
ValidateAndFill now checks the DB query result and returns sentinel errors
(ErrDatabase, ErrInvalidCredentials, ErrUserEmptyCredentials) instead of
hardcoded Chinese strings. The controller maps each sentinel to the
appropriate i18n message, so users see "please contact admin" on DB errors
instead of a misleading "wrong password" message. Non-DB errors still
return a unified vague response to avoid leaking user existence.
2026-04-12 17:11:20 +08:00
CaIon ed7f839911 feat: improve model price error UX with role-aware messages and cleaner UI
- Backend: differentiate error messages for admin vs regular users in price.go
- Backend: include error_code in channel test response for structured error handling
- Frontend: render model_price_error as a styled card in Playground with admin nav button
- Frontend: show inline error details and settings link in channel test modal
- Frontend: parse error codes from both SSE and non-streaming API responses
- i18n: remove redundant "Settings" suffix from setting tab translations (en/fr/ru/ja/vi)
- i18n: update "Group & Model Pricing" translations across all locales
2026-04-11 17:19:38 +08:00
CaIon 040e8c1da8 feat: replace quota input with amount-first UI and atomic quota adjustment
- Refactor token, redemption, and user quota inputs to prioritize monetary
  amount entry, with raw quota input collapsed by default
- Add atomic quota adjustment modal for users with add/subtract/override modes,
  bypassing batch update queue for immediate DB consistency
- Make user quota fields readonly in edit form; all modifications go through
  the dedicated adjust-quota modal via POST /api/user/manage
- Add DecreaseUserQuota `db` parameter for direct DB writes, matching
  IncreaseUserQuota behavior
- Support negative quota display in amount conversion helpers
- Add i18n keys for all new UI strings across all locales
2026-04-09 22:44:53 +08:00
Seefs 0664bb3f65 Merge pull request #4076 from seefs001/ci/add-pr-check
ci: refine PR template and add PR submission checks
2026-04-09 14:35:38 +08:00
Seefs c7cf20391e fix: document render (#4153) 2026-04-09 14:35:31 +08:00
Calcium-Ion b07f0b9626 Merge pull request #4154 from seefs001/feature/vllm-extensions-params
feat: fill in some custom fields for vllm-omini
2026-04-09 14:35:05 +08:00
Calcium-Ion 53cf37a469 fix(ali): accept string usage values in task polling (#4155) 2026-04-09 14:34:44 +08:00
Seefs 3bda738ec1 fix: prefer explicit pricing for compact models (#4156) 2026-04-09 14:34:14 +08:00
NyaMisty 160cb28572 fix(zhipu_4v): use correct endpoint for coding plan image generation (#4146) 2026-04-09 14:33:48 +08:00
Seefs 274307b0a9 fix(ali): accept string usage values in task polling 2026-04-09 12:48:17 +08:00
Seefs a19a63b98c feat: fill in some custom fields for vllm-omini. 2026-04-09 12:41:51 +08:00
CaIon 78e4cb3cad feat(web): redesign group ratio rules with collapsible grouped layout
Rewrite GroupGroupRatioRules and GroupSpecialUsableRules to group rules
by user group in collapsible sections instead of a flat table. Default
collapsed to reduce visual clutter when many rules exist. Fix i18n
translations for ja, zh-TW with proper native text; add missing keys.
2026-04-08 17:09:42 +08:00
forsakenyang c734db34e8 feat: add minimax image generation relay support (#4103) 2026-04-08 16:57:44 +08:00
星野梦月 a18ea3cc16 feat: 支持强制使用 AUTH LOGIN 以解决 outlook 等邮箱的发件问题 (#4112)
* feat: 支持强制使用 AUTH LOGIN 以解决 outlook 等邮箱的发件问题

* fix: 修复通过 SSL 发送邮件时绕过 AUTH LOGIN 的问题

* fix: remove redundant branch, delete test file, add i18n translations

- Remove redundant else-if branch in SendEmail since auth is already
  computed via getSMTPAuth()
- Delete option_smtp_auth_test.go as requested
- Add i18n translations for '强制使用 AUTH LOGIN' checkbox
2026-04-08 16:53:10 +08:00
CaIon aafbd78887 feat(dashboard): add copy button next to API link in API info panel
Closes #4058
2026-04-08 16:39:50 +08:00
CaIon 77897a8101 feat(dashboard): enhance chart axes and update sorting logic 2026-04-08 15:57:26 +08:00
Calcium-Ion 9b4ffb0875 Merge pull request #4142 from seefs001/fix/skip_failure_option
fix: 修复 失败后不重试 配置项写到内存被覆盖
2026-04-08 15:45:02 +08:00
CaIon 606a4eee96 feat(dashboard): add admin user analytics and fix chart labels
- Add GET /api/data/users endpoint for user-grouped quota data (admin only)
- Add user consumption ranking (horizontal bar, top 10) and user consumption
  trend (area chart) tabs visible only to admin users
- Fix mislabeled "消耗趋势" tab to "调用趋势" (shows call counts, not quota)
- Add processUserData helper for user ranking and trend data extraction
- Add i18n keys for new tabs across all 7 locales
2026-04-08 15:44:01 +08:00
Calcium-Ion 9ffb85a36b Merge pull request #4068 from feitianbubu/seedance-support-duration
Seedance support duration
2026-04-08 15:01:25 +08:00
Seefs c3b8fa29b2 fix: 修复 失败后不重试 配置项写到内存被覆盖 2026-04-08 14:01:27 +08:00
Calcium-Ion a057eddac1 Merge pull request #4131 from binorxin/add-error-logs
chore: 添加 启用错误日志记录到env配置中
2026-04-08 13:46:18 +08:00
Calcium-Ion 1110403750 Merge pull request #4136 from QuantumNous/dependabot/go_modules/github.com/aws/aws-sdk-go-v2/service/bedrockruntime-1.50.4
chore(deps): bump github.com/aws/aws-sdk-go-v2/service/bedrockruntime from 1.50.0 to 1.50.4
2026-04-08 13:43:34 +08:00
Calcium-Ion 3a2aecbc01 Merge pull request #4123 from bbbugg/fix/enabled-api
fix(pricing): add filtering for pricing based on usable groups
2026-04-08 13:43:02 +08:00
Calcium-Ion 49648d8b80 Merge pull request #4128 from zuiho-kai/fix/claude-stream-usage-overwrite
fix: Claude 流式断流时不再整份覆盖 usage,保留 cache 计费字段
2026-04-08 13:42:39 +08:00
Seefs 59d5aef393 fix: 修复 失败后不重试 配置项写到内存被覆盖 2026-04-08 13:41:31 +08:00
Seefs 48695e0e6f Merge pull request #3350 from goodmorning10/feat/error-boundary
feat: add ErrorBoundary to prevent full-page crashes
2026-04-08 12:21:11 +08:00
Seefs e96ca77542 Merge branch 'main' into feat/error-boundary 2026-04-08 12:20:50 +08:00
Seefs 1ad2557668 Merge pull request #3488 from clansty/feature/channel-affinity-include-model
feat: add IncludeModelName option to channel affinity rules
2026-04-08 11:54:31 +08:00
dependabot[bot] ded3bb9cb1 chore(deps): bump github.com/aws/aws-sdk-go-v2/service/bedrockruntime
Bumps [github.com/aws/aws-sdk-go-v2/service/bedrockruntime](https://github.com/aws/aws-sdk-go-v2) from 1.50.0 to 1.50.4.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.50.0...service/ssm/v1.50.4)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/bedrockruntime
  dependency-version: 1.50.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 01:23:22 +00:00
CaIon dc83c4af31 refactor(settings): update RatioSetting component to use ModelPricingCombined and adjust tab structure
- Replaced ModelRatioSettings with ModelPricingCombined in the RatioSetting component.
- Updated tab structure to prioritize pricing settings over model settings.
- Removed unused imports for ModelRatioSettings and ModelSettingsVisualEditor.
2026-04-08 01:00:09 +08:00
borx cf1b485389 add 添加 启用错误日志记录到env配置中 2026-04-07 21:12:13 +08:00
Clansty 741aaf4436 fix: wrap scope tag labels with t() for i18n support 2026-04-07 20:00:34 +08:00
zuiho c66636a0c7 fix: 采纳 CodeRabbit 建议,!Done 时也用 fallback 覆盖占位 CompletionTokens
message_start 阶段可能给 CompletionTokens 非零占位值,
只检查 == 0 不够,加上 !Done && fallback > current 条件。
2026-04-07 17:52:11 +08:00
zuiho f7cdc727df fix: Claude 流式断流时不再整份覆盖 usage,保留 cache 计费字段
HandleStreamFinalResponse 在 !Done 时调用 ResponseText2Usage 整份覆盖
claudeInfo.Usage,导致 message_start 已获取的 CacheReadInputTokens、
CacheCreationInputTokens 等字段丢失,prompt 退化为占位值 1。

修复:
- 只补缺失的 CompletionTokens/PromptTokens,保留已有 cache 数据
- PromptTokens 兜底改用 info.GetEstimatePromptTokens()(与其他渠道对齐)

Fixes #4127
2026-04-07 17:41:08 +08:00
bbbugg 07843d7898 fix(pricing): add filtering for pricing based on usable groups 2026-04-07 15:56:28 +08:00
irongit 559c98f261 feat(web): add ErrorBoundary to prevent full-page crashes 2026-04-06 22:32:19 +08:00
Calcium-Ion 960bf9c49e Merge pull request #4114 from RedwindA/fix/4110
feat(token): add batch API for fetching token keys
2026-04-06 19:49:10 +08:00
RedwindA 12a48c620e feat(token): add batch API for fetching token keys
Add new endpoint POST /api/token/batch/keys to fetch multiple
token keys in a single request, improving performance when
exporting or copying multiple tokens.

- Backend: Add GetTokenKeysBatch controller and GetTokenKeysByIds model
- Backend: Add route with CriticalRateLimit and DisableCache middleware
- Frontend: Add fetchTokenKeysBatch helper function
- Frontend: Update useTokensData to use batch API for token export
2026-04-06 19:46:01 +08:00
Calcium-Ion eacc245bad Merge pull request #4106 from HynoR/feat/fix
feat(playground): enhance max_tokens handling and input sanitization
2026-04-06 16:01:28 +08:00
CaIon 03758a4a85 refactor(file-source): unify file source creation and enhance caching mechanisms 2026-04-06 15:54:55 +08:00
CaIon 8fc0eb78e2 feat(billing): enhance task billing process with video input detection and updated pricing logic
- Added `EstimateBilling` function to check for video input in request metadata and return corresponding discount ratios.
- Updated `ModelPriceHelperPerCall` to incorporate new pricing logic based on model ratios and video input.
- Enhanced task billing logs to include model ratio information and adjusted calculations for actual quota based on additional multipliers.
- Introduced `renderTaskBillingProcess` to improve rendering of task billing information in the UI.
2026-04-06 15:54:55 +08:00
HynoR 427fb7eaf6 refactor(playground): remove playgroundMaxTokens helper and update input handling
- Deleted the `playgroundMaxTokens` helper functions and their associated tests.
- Updated `loadConfig` and `usePlaygroundState` to handle `max_tokens` directly without sanitization.
- Simplified input handling in `usePlaygroundState` to directly set values without normalization.
2026-04-06 00:48:06 +08:00
HynoR 4cd0e3651d feat(playground): enhance max_tokens handling and input sanitization
- Introduced `sanitizePlaygroundInputs` to normalize `max_tokens` input values.
- Updated `loadConfig` to utilize the new sanitization function.
- Replaced `Input` with `InputNumber` for `max_tokens` in `ParameterControl` for better user experience.
- Modified API payload building logic to handle `max_tokens` more robustly.
- Added tests for new helper functions in `playgroundMaxTokens.js` to ensure correct behavior.
2026-04-06 00:40:08 +08:00
Calcium-Ion 677d02f2ab Merge pull request #4097 from QuantumNous/dependabot/npm_and_yarn/electron/lodash-4.18.1
chore(deps-dev): bump lodash from 4.17.23 to 4.18.1 in /electron
2026-04-05 15:30:22 +08:00
dependabot[bot] c583382af7 chore(deps-dev): bump lodash from 4.17.23 to 4.18.1 in /electron
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-05 06:04:44 +00:00
Calcium-Ion b1950655e7 Merge pull request #3523 from QuantumNous/dependabot/npm_and_yarn/electron/xmldom/xmldom-0.8.12
chore(deps-dev): bump @xmldom/xmldom from 0.8.11 to 0.8.12 in /electron
2026-04-05 14:04:19 +08:00
Calcium-Ion 22cbbcab4c Merge pull request #4080 from QuantumNous/dependabot/npm_and_yarn/electron/electron-39.8.5
chore(deps-dev): bump electron from 35.7.5 to 39.8.5 in /electron
2026-04-05 14:03:40 +08:00
Calcium-Ion 873067f7d1 Merge pull request #4090 from seefs001/fix/claude2openai-usage
fix: emit claude message_delta for usage-only final stream chunk
2026-04-04 21:09:46 +08:00
Seefs 82c2008d2c fix: emit claude message_delta for usage-only final stream chunk 2026-04-04 20:21:13 +08:00
Seefs 495e4f5e17 Merge pull request #4087 from D26FORWARD/fix/gemini-native-stream-detection
fix(gemini): detect streaming from URL path :streamGenerateContent
2026-04-04 19:04:32 +08:00
D26FORWARD 23fde25b15 fix(gemini): detect streaming from URL path :streamGenerateContent
Google's native Gemini API uses the URL action :streamGenerateContent
to indicate streaming intent, not just the ?alt=sse query parameter.
The current IsStream() only checks c.Query("alt") == "sse", causing
all :streamGenerateContent requests (without ?alt=sse) to be treated
as non-streaming.

This adds a strings.Contains check for "streamGenerateContent" in the
request URL path, so both streaming indicators are recognized.
2026-04-04 16:11:13 +08:00
dependabot[bot] ac90d9f185 chore(deps-dev): bump electron from 35.7.5 to 39.8.5 in /electron
Bumps [electron](https://github.com/electron/electron) from 35.7.5 to 39.8.5.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v35.7.5...v39.8.5)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 39.8.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-03 22:21:15 +00:00
CaIon bb5b9eaca2 fix(relay-claude): set TopP to nil in Claude request to align with API requirements 2026-04-03 20:18:28 +08:00
feitianbubu b713e277cd feat: metadata correct parse 2026-04-03 15:28:08 +08:00
feitianbubu 08a5243bbc feat: TaskSubmitReq support Duration 2026-04-03 15:00:23 +08:00
CaIon c9611c493f feat(usage-logs): enhance stream status display with error tooltip 2026-04-02 22:27:11 +08:00
dependabot[bot] 314ae40820 chore(deps-dev): bump @xmldom/xmldom from 0.8.11 to 0.8.12 in /electron
Bumps [@xmldom/xmldom](https://github.com/xmldom/xmldom) from 0.8.11 to 0.8.12.
- [Release notes](https://github.com/xmldom/xmldom/releases)
- [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/xmldom/xmldom/compare/0.8.11...0.8.12)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-version: 0.8.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 04:51:17 +00:00
Clansty 116e0b8f1c feat: add include_model_name UI switch to channel affinity settings 2026-03-29 02:48:37 +08:00
Clansty 70560d5371 feat: add IncludeModelName option to channel affinity rules for per-model affinity tracking 2026-03-29 02:22:24 +08:00
174 changed files with 13128 additions and 3651 deletions
+2
View File
@@ -19,6 +19,8 @@
# HOSTNAME=your-hostname
# 数据库相关配置
# 启用错误日志记录
# ERROR_LOG_ENABLED=true
# 数据库连接字符串
# SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true
# 日志数据库连接字符串
+28
View File
@@ -0,0 +1,28 @@
# ⚠️ 提交说明 / PR Notice
> [!IMPORTANT]
>
> - 请提供**人工撰写**的简洁摘要,避免直接粘贴未经整理的 AI 输出。
## 📝 变更描述 / Description
(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容)
## 🚀 变更类型 / Type of change
- [ ] 🐛 Bug 修复 (Bug fix) - *请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug*
- [ ] ✨ 新功能 (New feature) - *重大特性建议先通过 Issue 沟通*
- [ ] ⚡ 性能优化 / 重构 (Refactor)
- [ ] 📝 文档更新 (Documentation)
## 🔗 关联任务 / Related Issue
- Closes # (如有)
## ✅ 提交前检查项 / Checklist
- [ ] **人工确认:** 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。
- [ ] **Bug fix 说明:** 若此 PR 标记为 `Bug fix`,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
- [ ] **变更理解:** 我已理解这些更改的工作原理及可能影响。
- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
## 📸 运行证明 / Proof of Work
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
@@ -1,29 +0,0 @@
# ⚠️ 提交警告 / PR Warning
> **请注意:** 请提供**人工撰写**的简洁摘要。包含大量 AI 灌水内容、逻辑混乱或无视模版的 PR **可能会被无视或直接关闭**。
---
## 💡 沟通提示 / Pre-submission
> **重大功能变更?** 请先提交 Issue 交流,避免无效劳动。
## 📝 变更描述 / Description
(简述:做了什么?为什么这样改能生效?你必须理解代码逻辑,禁止粘贴 AI 废话)
## 🚀 变更类型 / Type of change
- [ ] 🐛 Bug 修复 (Bug fix)
- [ ] ✨ 新功能 (New feature) - *重大特性建议先 Issue 沟通*
- [ ] ⚡ 性能优化 / 重构 (Refactor)
- [ ] 📝 文档更新 (Documentation)
## 🔗 关联任务 / Related Issue
- Closes # (如有)
## ✅ 提交前检查项 / Checklist
- [ ] **人工确认:** 我已亲自撰写此描述,去除了 AI 原始输出的冗余。
- [ ] **深度理解:** 我已**完全理解**这些更改的工作原理及潜在影响。
- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
- [ ] **本地验证:** 已在本地运行并通过了测试或手动验证。
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
## 📸 运行证明 / Proof of Work
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
+33
View File
@@ -0,0 +1,33 @@
name: PR Check
permissions:
contents: read
issues: read
pull-requests: read
on:
pull_request_target:
types: [opened, reopened]
jobs:
pr-quality:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0.2.1
with:
max-failures: 4
require-description: true
# require-linked-issue: false
blocked-terms: |
🤖 Generated with Claude Code
require-pr-template: true
strict-pr-template-sections: "✅ 提交前检查项 / Checklist"
detect-spam-usernames: true
min-account-age: 30
failure-add-pr-labels: "pr-check-failed"
failure-pr-message: "感谢您的提交。由于该 PR 未遵循我们的贡献模板,且被识别为缺乏人工参与的纯 AI 生成内容 (AI Slop),我们将先予以关闭。我们更欢迎经过人工审核、验证并带有个人思考的贡献。如果您认为这其中存在误解,请回复告知。/ Thank you for your submission. This PR has been closed because it does not follow our contribution template and has been identified as purely AI-generated content (AI Slop) without meaningful human involvement. We prioritize contributions that are human-verified and reflect individual effort. If you believe this is a mistake, please let us know by replying to this comment."
close-pr: true
+2
View File
@@ -29,3 +29,5 @@ data/
.gomodcache/
.gocache-temp
.gopath
token_estimator_test.go
+5
View File
@@ -80,6 +80,7 @@ var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true}
var SMTPServer = ""
var SMTPPort = 587
var SMTPSSLEnabled = false
var SMTPForceAuthLogin = false
var SMTPAccount = ""
var SMTPFrom = ""
var SMTPToken = ""
@@ -115,6 +116,10 @@ var RetryTimes = 0
var IsMasterNode bool
// NodeName 节点名称,从 NODE_NAME 环境变量读取;
// 用于审计日志中标识节点身份,在容器/K8s 部署时比自动探测到的容器内网 IP 更具可读性。
var NodeName = ""
var requestInterval int
var RequestInterval time.Duration
+15 -4
View File
@@ -19,6 +19,20 @@ func generateMessageID() (string, error) {
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil
}
func shouldUseSMTPLoginAuth() bool {
if SMTPForceAuthLogin {
return true
}
return isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer)
}
func getSMTPAuth() smtp.Auth {
if shouldUseSMTPLoginAuth() {
return LoginAuth(SMTPAccount, SMTPToken)
}
return smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
}
func SendEmail(subject string, receiver string, content string) error {
if SMTPFrom == "" { // for compatibility
SMTPFrom = SMTPAccount
@@ -38,7 +52,7 @@ func SendEmail(subject string, receiver string, content string) error {
"Message-ID: %s\r\n"+ // 添加 Message-ID 头
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), id, content))
auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
auth := getSMTPAuth()
addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
to := strings.Split(receiver, ";")
var err error
@@ -80,9 +94,6 @@ func SendEmail(subject string, receiver string, content string) error {
if err != nil {
return err
}
} else if isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer) {
auth = LoginAuth(SMTPAccount, SMTPToken)
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
} else {
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
}
+1
View File
@@ -82,6 +82,7 @@ func InitEnv() {
DebugEnabled = os.Getenv("DEBUG") == "true"
MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true"
IsMasterNode = os.Getenv("NODE_TYPE") != "slave"
NodeName = os.Getenv("NODE_NAME")
TLSInsecureSkipVerify = GetEnvOrDefaultBool("TLS_INSECURE_SKIP_VERIFY", false)
if TLSInsecureSkipVerify {
if tr, ok := http.DefaultTransport.(*http.Transport); ok && tr != nil {
+72 -28
View File
@@ -29,45 +29,89 @@ var DefaultSSRFProtection = &SSRFProtection{
AllowedPorts: []int{},
}
// isPrivateIP 检查IP是否为私有地址
// privateIPv4Nets IPv4 私有/保留/特殊用途网段
// 参考 IANA IPv4 Special-Purpose Address Registry
// https://www.iana.org/assignments/iana-ipv4-special-registry/
var privateIPv4Nets = []net.IPNet{
{IP: net.IPv4(0, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 0.0.0.0/8 ("This network" / 未指定)
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8 (私有)
{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}, // 100.64.0.0/10 (运营商级 NAT / CGNAT)
{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8 (回环)
{IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地)
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12 (私有)
{IP: net.IPv4(192, 0, 0, 0), Mask: net.CIDRMask(24, 32)}, // 192.0.0.0/24 (IETF 协议分配)
{IP: net.IPv4(192, 0, 2, 0), Mask: net.CIDRMask(24, 32)}, // 192.0.2.0/24 (TEST-NET-1)
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16 (私有)
{IP: net.IPv4(198, 18, 0, 0), Mask: net.CIDRMask(15, 32)}, // 198.18.0.0/15 (基准测试)
{IP: net.IPv4(198, 51, 100, 0), Mask: net.CIDRMask(24, 32)}, // 198.51.100.0/24 (TEST-NET-2)
{IP: net.IPv4(203, 0, 113, 0), Mask: net.CIDRMask(24, 32)}, // 203.0.113.0/24 (TEST-NET-3)
{IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播)
{IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留)
{IP: net.IPv4(255, 255, 255, 255), Mask: net.CIDRMask(32, 32)}, // 255.255.255.255/32 (受限广播)
}
// privateIPv6Nets IPv6 私有/保留/特殊用途网段
// 参考 IANA IPv6 Special-Purpose Address Registry
// https://www.iana.org/assignments/iana-ipv6-special-registry/
var privateIPv6Nets = func() []net.IPNet {
cidrs := []string{
"::/128", // 未指定地址
"::1/128", // 回环
"::ffff:0:0/96", // IPv4-mapped
"64:ff9b::/96", // IPv4/IPv6 translation
"100::/64", // Discard-Only
"2001::/23", // IETF Protocol Assignments
"2001:db8::/32", // 文档
"fc00::/7", // Unique Local Address (ULA)
"fe80::/10", // 链路本地
"ff00::/8", // 组播
}
nets := make([]net.IPNet, 0, len(cidrs))
for _, c := range cidrs {
if _, n, err := net.ParseCIDR(c); err == nil && n != nil {
nets = append(nets, *n)
}
}
return nets
}()
// isPrivateIP 检查IP是否为私有/保留/特殊用途地址
func isPrivateIP(ip net.IP) bool {
if ip == nil {
return true
}
// 未指定地址 (0.0.0.0, ::)
if ip.IsUnspecified() {
return true
}
// 回环、链路本地 (unicast/multicast)
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
// 检查私有网段
private := []net.IPNet{
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
{IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地)
{IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播)
{IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留)
// 接口本地组播 (IPv6 ff01::/16 等)
if ip.IsInterfaceLocalMulticast() {
return true
}
for _, privateNet := range private {
if v4 := ip.To4(); v4 != nil {
for _, privateNet := range privateIPv4Nets {
if privateNet.Contains(v4) {
return true
}
}
return false
}
// IPv6 检查
for _, privateNet := range privateIPv6Nets {
if privateNet.Contains(ip) {
return true
}
}
// 检查IPv6私有地址
if ip.To4() == nil {
// IPv6 loopback
if ip.Equal(net.IPv6loopback) {
return true
}
// IPv6 link-local
if strings.HasPrefix(ip.String(), "fe80:") {
return true
}
// IPv6 unique local
if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") {
return true
}
// 兜底: Go 标准库识别的其他私有地址
if ip.IsPrivate() {
return true
}
return false
}
+1
View File
@@ -65,4 +65,5 @@ const (
// ContextKeyLanguage stores the user's language preference for i18n
ContextKeyLanguage ContextKey = "language"
ContextKeyIsStream ContextKey = "is_stream"
)
+51 -9
View File
@@ -150,6 +150,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
}
}
cache.WriteContext(c)
c.Set("id", 1)
//c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
c.Request.Header.Set("Content-Type", "application/json")
@@ -274,7 +275,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeModelPriceError),
newAPIError: types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest)),
}
}
@@ -459,7 +460,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
newAPIError: types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError),
}
}
if bodyErr := detectErrorFromTestResponseBody(respBody); bodyErr != nil {
if bodyErr := validateTestResponseBody(respBody, isStream); bodyErr != nil {
return testResult{
context: c,
localErr: bodyErr,
@@ -569,6 +570,42 @@ func detectErrorFromTestResponseBody(respBody []byte) error {
return nil
}
func validateStreamTestResponseBody(respBody []byte) error {
b := bytes.TrimSpace(respBody)
if len(b) == 0 {
return errors.New("stream response body is empty")
}
for _, line := range bytes.Split(b, []byte{'\n'}) {
line = bytes.TrimSpace(line)
if len(line) == 0 || !bytes.HasPrefix(line, []byte("data:")) {
continue
}
payload := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:")))
if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) {
continue
}
return nil
}
return errors.New("stream response body does not contain a valid stream event")
}
func validateTestResponseBody(respBody []byte, isStream bool) error {
if bodyErr := detectErrorFromTestResponseBody(respBody); bodyErr != nil {
return bodyErr
}
if isStream {
return validateStreamTestResponseBody(respBody)
}
return nil
}
func shouldUseStreamForAutomaticChannelTest(channel *model.Channel) bool {
return channel != nil && channel.Type == constant.ChannelTypeCodex
}
func detectErrorMessageFromJSONBytes(jsonBytes []byte) string {
if len(jsonBytes) == 0 {
return ""
@@ -756,11 +793,15 @@ func TestChannel(c *gin.Context) {
tik := time.Now()
result := testChannel(channel, testModel, endpointType, isStream)
if result.localErr != nil {
c.JSON(http.StatusOK, gin.H{
resp := gin.H{
"success": false,
"message": result.localErr.Error(),
"time": 0.0,
})
}
if result.newAPIError != nil {
resp["error_code"] = result.newAPIError.GetErrorCode()
}
c.JSON(http.StatusOK, resp)
return
}
tok := time.Now()
@@ -769,9 +810,10 @@ func TestChannel(c *gin.Context) {
consumedTime := float64(milliseconds) / 1000.0
if result.newAPIError != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": result.newAPIError.Error(),
"time": consumedTime,
"success": false,
"message": result.newAPIError.Error(),
"time": consumedTime,
"error_code": result.newAPIError.GetErrorCode(),
})
return
}
@@ -816,7 +858,7 @@ func testAllChannels(notify bool) error {
}
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
tik := time.Now()
result := testChannel(channel, "", "", false)
result := testChannel(channel, "", "", shouldUseStreamForAutomaticChannelTest(channel))
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
@@ -824,7 +866,7 @@ func testAllChannels(notify bool) error {
newAPIError := result.newAPIError
// request error disables the channel
if newAPIError != nil {
shouldBanChannel = service.ShouldDisableChannel(channel.Type, result.newAPIError)
shouldBanChannel = service.ShouldDisableChannel(result.newAPIError)
}
// 当错误检查通过,才检查响应时间
+12 -2
View File
@@ -27,6 +27,15 @@ var completionRatioMetaOptionKeys = []string{
"AudioCompletionRatio",
}
func isVisiblePublicKeyOption(key string) bool {
switch key {
case "WaffoPancakeWebhookPublicKey", "WaffoPancakeWebhookTestKey":
return true
default:
return false
}
}
func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
if strings.TrimSpace(raw) == "" {
return
@@ -66,11 +75,12 @@ func GetOptions(c *gin.Context) {
common.OptionMapRWMutex.Lock()
for k, v := range common.OptionMap {
value := common.Interface2String(v)
if strings.HasSuffix(k, "Token") ||
isSensitiveKey := strings.HasSuffix(k, "Token") ||
strings.HasSuffix(k, "Secret") ||
strings.HasSuffix(k, "Key") ||
strings.HasSuffix(k, "secret") ||
strings.HasSuffix(k, "api_key") {
strings.HasSuffix(k, "api_key")
if isSensitiveKey && !isVisiblePublicKeyOption(k) {
continue
}
options = append(options, &model.Option{
+70
View File
@@ -36,6 +36,10 @@ func PasskeyRegisterBegin(c *gin.Context) {
return
}
if !requirePasskeyRegistrationVerification(c, user.Id) {
return
}
credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
common.ApiError(c, err)
@@ -96,6 +100,10 @@ func PasskeyRegisterFinish(c *gin.Context) {
return
}
if !requirePasskeyRegistrationVerification(c, user.Id) {
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
@@ -151,6 +159,10 @@ func PasskeyDelete(c *gin.Context) {
return
}
if !requirePasskeyDeleteVerification(c, user.Id) {
return
}
if err := model.DeletePasskeyByUserID(user.Id); err != nil {
common.ApiError(c, err)
return
@@ -474,6 +486,7 @@ func PasskeyVerifyFinish(c *gin.Context) {
// Mark passkey as ready; /api/verify will convert this into the final secure verification session.
session.Set(PasskeyReadySessionKey, time.Now().Unix())
session.Delete(SecureVerificationSessionKey)
session.Delete(secureVerificationMethodSessionKey)
if err := session.Save(); err != nil {
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
return
@@ -504,3 +517,60 @@ func getSessionUser(c *gin.Context) (*model.User, error) {
}
return user, nil
}
func requirePasskeyRegistrationVerification(c *gin.Context, userID int) bool {
twoFA, err := model.GetTwoFAByUserId(userID)
if err != nil {
common.ApiError(c, err)
return false
}
if twoFA == nil || !twoFA.IsEnabled {
return true
}
return requireSecureVerificationMethod(c, secureVerificationMethod2FA)
}
func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool {
twoFA, err := model.GetTwoFAByUserId(userID)
if err != nil {
common.ApiError(c, err)
return false
}
if twoFA != nil && twoFA.IsEnabled {
return requireSecureVerificationMethod(c, secureVerificationMethod2FA)
}
_, err = model.GetPasskeyByUserID(userID)
if err != nil {
if errors.Is(err, model.ErrPasskeyNotFound) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
return false
}
common.ApiError(c, err)
return false
}
return requireSecureVerificationMethod(c, secureVerificationMethodPasskey)
}
func requireSecureVerificationMethod(c *gin.Context, method string) bool {
session := sessions.Default(c)
verifiedAt, ok := session.Get(SecureVerificationSessionKey).(int64)
if !ok || time.Now().Unix()-verifiedAt >= SecureVerificationTimeout {
session.Delete(SecureVerificationSessionKey)
session.Delete(secureVerificationMethodSessionKey)
_ = session.Save()
common.ApiErrorMsg(c, "请先完成安全验证")
return false
}
if verifiedMethod, ok := session.Get(secureVerificationMethodSessionKey).(string); !ok || verifiedMethod != method {
common.ApiErrorMsg(c, "请先完成对应的安全验证")
return false
}
return true
}
+100
View File
@@ -0,0 +1,100 @@
package controller
import (
"strings"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
)
func isStripeTopUpEnabled() bool {
return strings.TrimSpace(setting.StripeApiSecret) != "" &&
strings.TrimSpace(setting.StripeWebhookSecret) != "" &&
strings.TrimSpace(setting.StripePriceId) != ""
}
func isStripeWebhookConfigured() bool {
return strings.TrimSpace(setting.StripeWebhookSecret) != ""
}
func isStripeWebhookEnabled() bool {
return isStripeTopUpEnabled()
}
func isCreemTopUpEnabled() bool {
products := strings.TrimSpace(setting.CreemProducts)
return strings.TrimSpace(setting.CreemApiKey) != "" &&
products != "" &&
products != "[]"
}
func isCreemWebhookConfigured() bool {
return strings.TrimSpace(setting.CreemWebhookSecret) != ""
}
func isCreemWebhookEnabled() bool {
return isCreemTopUpEnabled() && isCreemWebhookConfigured()
}
func isWaffoTopUpEnabled() bool {
if !setting.WaffoEnabled {
return false
}
return isWaffoWebhookConfigured()
}
func isWaffoWebhookConfigured() bool {
if setting.WaffoSandbox {
return strings.TrimSpace(setting.WaffoSandboxApiKey) != "" &&
strings.TrimSpace(setting.WaffoSandboxPrivateKey) != "" &&
strings.TrimSpace(setting.WaffoSandboxPublicCert) != ""
}
return strings.TrimSpace(setting.WaffoApiKey) != "" &&
strings.TrimSpace(setting.WaffoPrivateKey) != "" &&
strings.TrimSpace(setting.WaffoPublicCert) != ""
}
func isWaffoWebhookEnabled() bool {
return isWaffoTopUpEnabled()
}
func isWaffoPancakeTopUpEnabled() bool {
if !setting.WaffoPancakeEnabled {
return false
}
return isWaffoPancakeWebhookConfigured() &&
strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" &&
strings.TrimSpace(setting.WaffoPancakeStoreID) != "" &&
strings.TrimSpace(setting.WaffoPancakeProductID) != ""
}
func isWaffoPancakeWebhookConfigured() bool {
currentWebhookKey := strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey)
if setting.WaffoPancakeSandbox {
currentWebhookKey = strings.TrimSpace(setting.WaffoPancakeWebhookTestKey)
}
return currentWebhookKey != ""
}
func isWaffoPancakeWebhookEnabled() bool {
return isWaffoPancakeTopUpEnabled()
}
func isEpayTopUpEnabled() bool {
return isEpayWebhookConfigured() && len(operation_setting.PayMethods) > 0
}
func isEpayWebhookConfigured() bool {
return strings.TrimSpace(operation_setting.PayAddress) != "" &&
strings.TrimSpace(operation_setting.EpayId) != "" &&
strings.TrimSpace(operation_setting.EpayKey) != ""
}
func isEpayWebhookEnabled() bool {
return isEpayTopUpEnabled()
}
@@ -0,0 +1,166 @@
package controller
import (
"testing"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/stretchr/testify/require"
)
func TestStripeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
originalAPISecret := setting.StripeApiSecret
originalWebhookSecret := setting.StripeWebhookSecret
originalPriceID := setting.StripePriceId
t.Cleanup(func() {
setting.StripeApiSecret = originalAPISecret
setting.StripeWebhookSecret = originalWebhookSecret
setting.StripePriceId = originalPriceID
})
setting.StripeWebhookSecret = ""
setting.StripeApiSecret = "sk_test_123"
setting.StripePriceId = "price_123"
require.False(t, isStripeWebhookEnabled())
setting.StripeWebhookSecret = "whsec_test"
require.True(t, isStripeWebhookEnabled())
setting.StripePriceId = ""
require.False(t, isStripeWebhookEnabled())
}
func TestCreemWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
originalAPIKey := setting.CreemApiKey
originalProducts := setting.CreemProducts
originalWebhookSecret := setting.CreemWebhookSecret
t.Cleanup(func() {
setting.CreemApiKey = originalAPIKey
setting.CreemProducts = originalProducts
setting.CreemWebhookSecret = originalWebhookSecret
})
setting.CreemWebhookSecret = ""
setting.CreemApiKey = "creem_api_key"
setting.CreemProducts = `[{"productId":"prod_123"}]`
require.False(t, isCreemWebhookEnabled())
setting.CreemWebhookSecret = "creem_secret"
require.True(t, isCreemWebhookEnabled())
setting.CreemProducts = "[]"
require.False(t, isCreemWebhookEnabled())
}
func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
originalEnabled := setting.WaffoEnabled
originalSandbox := setting.WaffoSandbox
originalAPIKey := setting.WaffoApiKey
originalPrivateKey := setting.WaffoPrivateKey
originalPublicCert := setting.WaffoPublicCert
originalSandboxAPIKey := setting.WaffoSandboxApiKey
originalSandboxPrivateKey := setting.WaffoSandboxPrivateKey
originalSandboxPublicCert := setting.WaffoSandboxPublicCert
t.Cleanup(func() {
setting.WaffoEnabled = originalEnabled
setting.WaffoSandbox = originalSandbox
setting.WaffoApiKey = originalAPIKey
setting.WaffoPrivateKey = originalPrivateKey
setting.WaffoPublicCert = originalPublicCert
setting.WaffoSandboxApiKey = originalSandboxAPIKey
setting.WaffoSandboxPrivateKey = originalSandboxPrivateKey
setting.WaffoSandboxPublicCert = originalSandboxPublicCert
})
setting.WaffoEnabled = true
setting.WaffoSandbox = false
setting.WaffoApiKey = ""
setting.WaffoPrivateKey = "private"
setting.WaffoPublicCert = "public"
require.False(t, isWaffoWebhookEnabled())
setting.WaffoApiKey = "api"
require.True(t, isWaffoWebhookEnabled())
setting.WaffoEnabled = false
require.False(t, isWaffoWebhookEnabled())
setting.WaffoEnabled = true
setting.WaffoSandbox = true
setting.WaffoSandboxApiKey = ""
setting.WaffoSandboxPrivateKey = "sandbox_private"
setting.WaffoSandboxPublicCert = "sandbox_public"
require.False(t, isWaffoWebhookEnabled())
setting.WaffoSandboxApiKey = "sandbox_api"
require.True(t, isWaffoWebhookEnabled())
}
func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
originalEnabled := setting.WaffoPancakeEnabled
originalSandbox := setting.WaffoPancakeSandbox
originalMerchantID := setting.WaffoPancakeMerchantID
originalPrivateKey := setting.WaffoPancakePrivateKey
originalWebhookPublicKey := setting.WaffoPancakeWebhookPublicKey
originalWebhookTestKey := setting.WaffoPancakeWebhookTestKey
originalStoreID := setting.WaffoPancakeStoreID
originalProductID := setting.WaffoPancakeProductID
t.Cleanup(func() {
setting.WaffoPancakeEnabled = originalEnabled
setting.WaffoPancakeSandbox = originalSandbox
setting.WaffoPancakeMerchantID = originalMerchantID
setting.WaffoPancakePrivateKey = originalPrivateKey
setting.WaffoPancakeWebhookPublicKey = originalWebhookPublicKey
setting.WaffoPancakeWebhookTestKey = originalWebhookTestKey
setting.WaffoPancakeStoreID = originalStoreID
setting.WaffoPancakeProductID = originalProductID
})
setting.WaffoPancakeEnabled = true
setting.WaffoPancakeSandbox = false
setting.WaffoPancakeMerchantID = "merchant"
setting.WaffoPancakePrivateKey = "private"
setting.WaffoPancakeStoreID = "store"
setting.WaffoPancakeProductID = "product"
setting.WaffoPancakeWebhookPublicKey = ""
require.False(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeWebhookPublicKey = "public"
require.True(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeEnabled = false
require.False(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeEnabled = true
setting.WaffoPancakeSandbox = true
setting.WaffoPancakeWebhookTestKey = ""
require.False(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeWebhookTestKey = "test_public"
require.True(t, isWaffoPancakeWebhookEnabled())
}
func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
originalPayAddress := operation_setting.PayAddress
originalEpayID := operation_setting.EpayId
originalEpayKey := operation_setting.EpayKey
originalPayMethods := operation_setting.PayMethods
t.Cleanup(func() {
operation_setting.PayAddress = originalPayAddress
operation_setting.EpayId = originalEpayID
operation_setting.EpayKey = originalEpayKey
operation_setting.PayMethods = originalPayMethods
})
operation_setting.PayAddress = "https://pay.example.com"
operation_setting.EpayId = "epay_id"
operation_setting.EpayKey = ""
operation_setting.PayMethods = []map[string]string{{"type": "alipay"}}
require.False(t, isEpayWebhookEnabled())
operation_setting.EpayKey = "epay_key"
require.True(t, isEpayWebhookEnabled())
operation_setting.PayMethods = nil
require.False(t, isEpayWebhookEnabled())
}
+26
View File
@@ -1,6 +1,7 @@
package controller
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -8,6 +9,30 @@ import (
"github.com/gin-gonic/gin"
)
func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing {
if len(pricing) == 0 {
return pricing
}
if len(usableGroup) == 0 {
return []model.Pricing{}
}
filtered := make([]model.Pricing, 0, len(pricing))
for _, item := range pricing {
if common.StringsContains(item.EnableGroup, "all") {
filtered = append(filtered, item)
continue
}
for _, group := range item.EnableGroup {
if _, ok := usableGroup[group]; ok {
filtered = append(filtered, item)
break
}
}
}
return filtered
}
func GetPricing(c *gin.Context) {
pricing := model.GetPricing()
userId, exists := c.Get("id")
@@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) {
}
usableGroup = service.GetUserUsableGroups(group)
pricing = filterPricingByUsableGroups(pricing, usableGroup)
// check groupRatio contains usableGroup
for group := range ratio_setting.GetGroupRatioCopy() {
if _, ok := usableGroup[group]; !ok {
+4 -4
View File
@@ -151,7 +151,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeModelPriceError)
newAPIError = types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest))
return
}
@@ -351,7 +351,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
if service.ShouldDisableChannel(channelError.ChannelType, err) && channelError.AutoBan {
if service.ShouldDisableChannel(err) && channelError.AutoBan {
gopool.Go(func() {
service.DisableChannel(channelError, err.ErrorWithStatusCode())
})
@@ -389,7 +389,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
startTime = time.Now()
}
useTimeSeconds := int(time.Since(startTime).Seconds())
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, false, userGroup, other)
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
}
}
@@ -581,7 +581,7 @@ func RelayTask(c *gin.Context) {
ModelRatio: relayInfo.PriceData.ModelRatio,
OtherRatios: relayInfo.PriceData.OtherRatios,
OriginModelName: relayInfo.OriginModelName,
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName),
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice,
}
task.Quota = result.Quota
task.Data = result.TaskData
+7 -3
View File
@@ -13,7 +13,10 @@ import (
const (
// SecureVerificationSessionKey means the user has fully passed secure verification.
SecureVerificationSessionKey = "secure_verified_at"
SecureVerificationSessionKey = "secure_verified_at"
secureVerificationMethodSessionKey = "secure_verified_method"
secureVerificationMethod2FA = "2fa"
secureVerificationMethodPasskey = "passkey"
// PasskeyReadySessionKey means WebAuthn finished and /api/verify can finalize step-up verification.
PasskeyReadySessionKey = "secure_passkey_ready_at"
// SecureVerificationTimeout 验证有效期(秒)
@@ -120,7 +123,7 @@ func UniversalVerify(c *gin.Context) {
}
// 验证成功,在 session 中记录时间戳
now, err := setSecureVerificationSession(c)
now, err := setSecureVerificationSession(c, req.Method)
if err != nil {
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
return
@@ -139,11 +142,12 @@ func UniversalVerify(c *gin.Context) {
})
}
func setSecureVerificationSession(c *gin.Context) (int64, error) {
func setSecureVerificationSession(c *gin.Context, method string) (int64, error) {
session := sessions.Default(c)
session.Delete(PasskeyReadySessionKey)
now := time.Now().Unix()
session.Set(SecureVerificationSessionKey, now)
session.Set(secureVerificationMethodSessionKey, method)
if err := session.Save(); err != nil {
return 0, err
}
+12 -10
View File
@@ -2,11 +2,13 @@ package controller
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
@@ -24,14 +26,14 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
// Keep body for debugging consistency (like RequestCreemPay)
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("read subscription creem pay req body err: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "read query error"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅支付请求读取失败 error=%q", err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "read query error"})
return
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
@@ -85,12 +87,12 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
PlanId: plan.Id,
Money: plan.PriceAmount,
TradeNo: referenceId,
PaymentMethod: PaymentMethodCreem,
PaymentMethod: model.PaymentMethodCreem,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
if err := order.Insert(); err != nil {
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
@@ -112,14 +114,14 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
Quota: 0,
}
checkoutUrl, err := genCreemLink(referenceId, product, user.Email, user.Username)
checkoutUrl, err := genCreemLink(c.Request.Context(), referenceId, product, user.Email, user.Username)
if err != nil {
log.Printf("获取Creem支付链接失败: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅支付链接创建失败 trade_no=%s product_id=%s error=%q", referenceId, product.ProductId, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
c.JSON(200, gin.H{
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"checkout_url": checkoutUrl,
+3 -3
View File
@@ -104,7 +104,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
ReturnUrl: returnUrl,
})
if err != nil {
_ = model.ExpireSubscriptionOrder(tradeNo)
_ = model.ExpireSubscriptionOrder(tradeNo, req.PaymentMethod)
common.ApiErrorMsg(c, "拉起支付失败")
return
}
@@ -156,7 +156,7 @@ func SubscriptionEpayNotify(c *gin.Context) {
LockOrder(verifyInfo.ServiceTradeNo)
defer UnlockOrder(verifyInfo.ServiceTradeNo)
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil {
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), verifyInfo.Type); err != nil {
_, _ = c.Writer.Write([]byte("fail"))
return
}
@@ -205,7 +205,7 @@ func SubscriptionEpayReturn(c *gin.Context) {
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
LockOrder(verifyInfo.ServiceTradeNo)
defer UnlockOrder(verifyInfo.ServiceTradeNo)
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil {
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), verifyInfo.Type); err != nil {
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
return
}
+3 -3
View File
@@ -2,12 +2,12 @@ package controller
import (
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/system_setting"
@@ -78,7 +78,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
payLink, err := genStripeSubscriptionLink(referenceId, user.StripeCustomer, user.Email, plan.StripePriceId)
if err != nil {
log.Println("获取Stripe Checkout支付链接失败", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 订阅支付链接创建失败 trade_no=%s plan_id=%d error=%q", referenceId, plan.Id, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
@@ -88,7 +88,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
PlanId: plan.Id,
Money: plan.PriceAmount,
TradeNo: referenceId,
PaymentMethod: PaymentMethodStripe,
PaymentMethod: model.PaymentMethodStripe,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
+23
View File
@@ -334,3 +334,26 @@ func DeleteTokenBatch(c *gin.Context) {
"data": count,
})
}
func GetTokenKeysBatch(c *gin.Context) {
tokenBatch := TokenBatch{}
if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
if len(tokenBatch.Ids) > 100 {
common.ApiErrorI18n(c, i18n.MsgBatchTooMany, map[string]any{"Max": 100})
return
}
userId := c.GetInt("id")
tokens, err := model.GetTokenKeysByIds(tokenBatch.Ids, userId)
if err != nil {
common.ApiError(c, err)
return
}
keysMap := make(map[int]string)
for _, t := range tokens {
keysMap[t.Id] = t.GetFullKey()
}
common.ApiSuccess(c, gin.H{"keys": keysMap})
}
+103 -57
View File
@@ -2,7 +2,7 @@ package controller
import (
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"sync"
@@ -27,7 +27,7 @@ func GetTopUpInfo(c *gin.Context) {
payMethods := operation_setting.PayMethods
// 如果启用了 Stripe 支付,添加到支付方法列表
if setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "" {
if isStripeTopUpEnabled() {
// 检查是否已经包含 Stripe
hasStripe := false
for _, method := range payMethods {
@@ -49,19 +49,11 @@ func GetTopUpInfo(c *gin.Context) {
}
// 如果启用了 Waffo 支付,添加到支付方法列表
enableWaffo := setting.WaffoEnabled &&
((!setting.WaffoSandbox &&
setting.WaffoApiKey != "" &&
setting.WaffoPrivateKey != "" &&
setting.WaffoPublicCert != "") ||
(setting.WaffoSandbox &&
setting.WaffoSandboxApiKey != "" &&
setting.WaffoSandboxPrivateKey != "" &&
setting.WaffoSandboxPublicCert != ""))
enableWaffo := isWaffoTopUpEnabled()
if enableWaffo {
hasWaffo := false
for _, method := range payMethods {
if method["type"] == "waffo" {
if method["type"] == model.PaymentMethodWaffo {
hasWaffo = true
break
}
@@ -70,7 +62,7 @@ func GetTopUpInfo(c *gin.Context) {
if !hasWaffo {
waffoMethod := map[string]string{
"name": "Waffo (Global Payment)",
"type": "waffo",
"type": model.PaymentMethodWaffo,
"color": "rgba(var(--semi-blue-5), 1)",
"min_topup": strconv.Itoa(setting.WaffoMinTopUp),
}
@@ -78,24 +70,46 @@ func GetTopUpInfo(c *gin.Context) {
}
}
enableWaffoPancake := isWaffoPancakeTopUpEnabled()
if enableWaffoPancake {
hasWaffoPancake := false
for _, method := range payMethods {
if method["type"] == model.PaymentMethodWaffoPancake {
hasWaffoPancake = true
break
}
}
if !hasWaffoPancake {
payMethods = append(payMethods, map[string]string{
"name": "Waffo Pancake",
"type": model.PaymentMethodWaffoPancake,
"color": "rgba(var(--semi-orange-5), 1)",
"min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp),
})
}
}
data := gin.H{
"enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "",
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
"enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]",
"enable_waffo_topup": enableWaffo,
"enable_online_topup": isEpayTopUpEnabled(),
"enable_stripe_topup": isStripeTopUpEnabled(),
"enable_creem_topup": isCreemTopUpEnabled(),
"enable_waffo_topup": enableWaffo,
"enable_waffo_pancake_topup": enableWaffoPancake,
"waffo_pay_methods": func() interface{} {
if enableWaffo {
return setting.GetWaffoPayMethods()
}
return nil
}(),
"creem_products": setting.CreemProducts,
"pay_methods": payMethods,
"min_topup": operation_setting.MinTopUp,
"stripe_min_topup": setting.StripeMinTopUp,
"waffo_min_topup": setting.WaffoMinTopUp,
"amount_options": operation_setting.GetPaymentSetting().AmountOptions,
"discount": operation_setting.GetPaymentSetting().AmountDiscount,
"creem_products": setting.CreemProducts,
"pay_methods": payMethods,
"min_topup": operation_setting.MinTopUp,
"stripe_min_topup": setting.StripeMinTopUp,
"waffo_min_topup": setting.WaffoMinTopUp,
"waffo_pancake_min_topup": setting.WaffoPancakeMinTopUp,
"amount_options": operation_setting.GetPaymentSetting().AmountOptions,
"discount": operation_setting.GetPaymentSetting().AmountDiscount,
}
common.ApiSuccess(c, data)
}
@@ -109,6 +123,17 @@ type AmountRequest struct {
Amount int64 `json:"amount"`
}
var nonEpayPaymentMethodsForCallback = []string{
model.PaymentMethodStripe,
model.PaymentMethodCreem,
model.PaymentMethodWaffo,
model.PaymentMethodWaffoPancake,
}
func isNonEpayPaymentMethodForEpayCallback(paymentMethod string) bool {
return lo.Contains(nonEpayPaymentMethodsForCallback, paymentMethod)
}
func GetEpayClient() *epay.Client {
if operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" {
return nil
@@ -167,28 +192,28 @@ func RequestEpay(c *gin.Context) {
var req EpayRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
if req.Amount < getMinTopup() {
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
return
}
id := c.GetInt("id")
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getPayMoney(req.Amount, group)
if payMoney < 0.01 {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
return
}
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付方式不存在"})
return
}
@@ -199,7 +224,7 @@ func RequestEpay(c *gin.Context) {
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
client := GetEpayClient()
if client == nil {
c.JSON(200, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
return
}
uri, params, err := client.Purchase(&epay.PurchaseArgs{
@@ -212,7 +237,8 @@ func RequestEpay(c *gin.Context) {
ReturnUrl: returnUrl,
})
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 拉起支付失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
amount := req.Amount
@@ -228,14 +254,16 @@ func RequestEpay(c *gin.Context) {
TradeNo: tradeNo,
PaymentMethod: req.PaymentMethod,
CreateTime: time.Now().Unix(),
Status: "pending",
Status: common.TopUpStatusPending,
}
err = topUp.Insert()
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 创建充值订单失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
c.JSON(200, gin.H{"message": "success", "data": params, "url": uri})
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值订单创建成功 user_id=%d trade_no=%s payment_method=%s amount=%d money=%.2f uri=%q params=%q", id, tradeNo, req.PaymentMethod, req.Amount, payMoney, uri, common.GetJsonString(params)))
c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
}
// tradeNo lock
@@ -281,12 +309,18 @@ func UnlockOrder(tradeNo string) {
}
func EpayNotify(c *gin.Context) {
if !isEpayWebhookEnabled() {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
_, _ = c.Writer.Write([]byte("fail"))
return
}
var params map[string]string
if c.Request.Method == "POST" {
// POST 请求:从 POST body 解析参数
if err := c.Request.ParseForm(); err != nil {
log.Println("易支付回调POST解析失败:", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook POST 表单解析失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
_, _ = c.Writer.Write([]byte("fail"))
return
}
@@ -301,50 +335,63 @@ func EpayNotify(c *gin.Context) {
return r
}, map[string]string{})
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 收到请求 path=%q client_ip=%s method=%s params=%q", c.Request.RequestURI, c.ClientIP(), c.Request.Method, common.GetJsonString(params)))
if len(params) == 0 {
log.Println("易支付回调参数为空")
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 参数为空 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
_, _ = c.Writer.Write([]byte("fail"))
return
}
client := GetEpayClient()
if client == nil {
log.Println("易支付回调失败 未找到配置信息")
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 client 未初始化 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
_, err := c.Writer.Write([]byte("fail"))
if err != nil {
log.Println("易支付回调写入失败")
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
}
return
}
verifyInfo, err := client.Verify(params)
if err == nil && verifyInfo.VerifyStatus {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
_, err := c.Writer.Write([]byte("success"))
if err != nil {
log.Println("易支付回调写入失败")
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), err.Error()))
}
} else {
_, err := c.Writer.Write([]byte("fail"))
if err != nil {
log.Println("易支付回调写入失败")
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
}
if err != nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
} else {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_status=false", c.Request.RequestURI, c.ClientIP()))
}
log.Println("易支付回调签名验证失败")
return
}
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
log.Println(verifyInfo)
LockOrder(verifyInfo.ServiceTradeNo)
defer UnlockOrder(verifyInfo.ServiceTradeNo)
topUp := model.GetTopUpByTradeNo(verifyInfo.ServiceTradeNo)
if topUp == nil {
log.Printf("易支付回调未找到订单: %v", verifyInfo)
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 回调订单不存在 trade_no=%s callback_type=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP(), common.GetJsonString(verifyInfo)))
return
}
if topUp.Status == "pending" {
topUp.Status = "success"
if isNonEpayPaymentMethodForEpayCallback(topUp.PaymentMethod) {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单支付方式不匹配 trade_no=%s order_payment_method=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentMethod, verifyInfo.Type, c.ClientIP()))
return
}
if topUp.PaymentMethod != verifyInfo.Type {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单支付方式不匹配 trade_no=%s order_payment_method=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentMethod, verifyInfo.Type, c.ClientIP()))
return
}
if topUp.Status == common.TopUpStatusPending {
topUp.Status = common.TopUpStatusSuccess
err := topUp.Update()
if err != nil {
log.Printf("易支付回调更新订单失败: %v", topUp)
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新充值订单失败 trade_no=%s user_id=%d client_ip=%s error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), err.Error(), common.GetJsonString(topUp)))
return
}
//user, _ := model.GetUserById(topUp.UserId, false)
@@ -354,14 +401,14 @@ func EpayNotify(c *gin.Context) {
quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart())
err = model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true)
if err != nil {
log.Printf("易支付回调更新用户失败: %v", topUp)
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新用户额度失败 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, err.Error(), common.GetJsonString(topUp)))
return
}
log.Printf("易支付回调更新用户成功 %v", topUp)
model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money))
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值成功 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d money=%.2f topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, topUp.Money, common.GetJsonString(topUp)))
model.RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), c.ClientIP(), topUp.PaymentMethod, "epay")
}
} else {
log.Printf("易支付异常回调: %v", verifyInfo)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 忽略事件 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
}
}
@@ -369,26 +416,26 @@ func RequestAmount(c *gin.Context) {
var req AmountRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
if req.Amount < getMinTopup() {
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
return
}
id := c.GetInt("id")
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getPayMoney(req.Amount, group)
if payMoney <= 0.01 {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
return
}
c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
}
func GetUserTopUps(c *gin.Context) {
@@ -457,10 +504,9 @@ func AdminCompleteTopUp(c *gin.Context) {
LockOrder(req.TradeNo)
defer UnlockOrder(req.TradeNo)
if err := model.ManualCompleteTopUp(req.TradeNo); err != nil {
if err := model.ManualCompleteTopUp(req.TradeNo, c.ClientIP()); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
+63 -71
View File
@@ -2,6 +2,7 @@ package controller
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
@@ -9,10 +10,10 @@ import (
"errors"
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"io"
"log"
"net/http"
"time"
@@ -20,10 +21,7 @@ import (
"github.com/thanhpk/randstr"
)
const (
PaymentMethodCreem = "creem"
CreemSignatureHeader = "creem-signature"
)
const CreemSignatureHeader = "creem-signature"
var creemAdaptor = &CreemAdaptor{}
@@ -37,9 +35,9 @@ func generateCreemSignature(payload string, secret string) string {
// 验证Creem webhook签名
func verifyCreemSignature(payload string, signature string, secret string) bool {
if secret == "" {
log.Printf("Creem webhook secret not set")
logger.LogWarn(context.Background(), fmt.Sprintf("Creem webhook secret 未配置 test_mode=%t signature=%q body=%q", setting.CreemTestMode, signature, payload))
if setting.CreemTestMode {
log.Printf("Skip Creem webhook sign verify in test mode")
logger.LogInfo(context.Background(), fmt.Sprintf("Creem webhook 验签已跳过 reason=test_mode signature=%q body=%q", signature, payload))
return true
}
return false
@@ -66,13 +64,13 @@ type CreemAdaptor struct {
}
func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
if req.PaymentMethod != PaymentMethodCreem {
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
if req.PaymentMethod != model.PaymentMethodCreem {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付渠道"})
return
}
if req.ProductId == "" {
c.JSON(200, gin.H{"message": "error", "data": "请选择产品"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "请选择产品"})
return
}
@@ -80,8 +78,8 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
var products []CreemProduct
err := json.Unmarshal([]byte(setting.CreemProducts), &products)
if err != nil {
log.Println("解析Creem产品列表失败", err)
c.JSON(200, gin.H{"message": "error", "data": "产品配置错误"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 产品配置解析失败 user_id=%d error=%q", c.GetInt("id"), err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品配置错误"})
return
}
@@ -95,7 +93,7 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
}
if selectedProduct == nil {
c.JSON(200, gin.H{"message": "error", "data": "产品不存在"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品不存在"})
return
}
@@ -108,32 +106,32 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
// 先创建订单记录,使用产品配置的金额和充值额度
topUp := &model.TopUp{
UserId: id,
Amount: selectedProduct.Quota, // 充值额度
Money: selectedProduct.Price, // 支付金额
TradeNo: referenceId,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
UserId: id,
Amount: selectedProduct.Quota, // 充值额度
Money: selectedProduct.Price, // 支付金额
TradeNo: referenceId,
PaymentMethod: model.PaymentMethodCreem,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
err = topUp.Insert()
if err != nil {
log.Printf("创建Creem订单失败: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 创建充值订单失败 user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
// 创建支付链接,传入用户邮箱
checkoutUrl, err := genCreemLink(referenceId, selectedProduct, user.Email, user.Username)
checkoutUrl, err := genCreemLink(c.Request.Context(), referenceId, selectedProduct, user.Email, user.Username)
if err != nil {
log.Printf("获取Creem支付链接失败: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 创建支付链接失败 user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
log.Printf("Creem订单创建成功 - 用户ID: %d, 订单号: %s, 产品: %s, 充值额度: %d, 支付金额: %.2f",
id, referenceId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值订单创建成功 user_id=%d trade_no=%s product_id=%s product_name=%q quota=%d money=%.2f", id, referenceId, selectedProduct.ProductId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price))
c.JSON(200, gin.H{
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"checkout_url": checkoutUrl,
@@ -148,20 +146,19 @@ func RequestCreemPay(c *gin.Context) {
// 读取body内容用于打印,同时保留原始数据供后续使用
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("read creem pay req body err: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "read query error"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 支付请求读取失败 error=%q", err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "read query error"})
return
}
// 打印body内容
log.Printf("creem pay request body: %s", string(bodyBytes))
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 支付请求已收到 user_id=%d body=%q", c.GetInt("id"), string(bodyBytes)))
// 重新设置body供后续的ShouldBindJSON使用
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
err = c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
creemAdaptor.RequestPay(c, &req)
@@ -229,35 +226,37 @@ type CreemWebhookEvent struct {
}
func CreemWebhook(c *gin.Context) {
if !isCreemWebhookEnabled() {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
c.AbortWithStatus(http.StatusForbidden)
return
}
// 读取body内容用于打印,同时保留原始数据供后续使用
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("读取Creem Webhook请求body失败: %v", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusBadRequest)
return
}
// 获取签名头
signature := c.GetHeader(CreemSignatureHeader)
// 打印关键信息(避免输出完整敏感payload)
log.Printf("Creem Webhook - URI: %s", c.Request.RequestURI)
if setting.CreemTestMode {
log.Printf("Creem Webhook - Signature: %s , Body: %s", signature, bodyBytes)
} else if signature == "" {
log.Printf("Creem Webhook缺少签名头")
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
if signature == "" {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 缺少签名 path=%q client_ip=%s body=%q", c.Request.RequestURI, c.ClientIP(), string(bodyBytes)))
c.AbortWithStatus(http.StatusUnauthorized)
return
}
// 验证签名
if !verifyCreemSignature(string(bodyBytes), signature, setting.CreemWebhookSecret) {
log.Printf("Creem Webhook签名验证失败")
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 验签失败 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
c.AbortWithStatus(http.StatusUnauthorized)
return
}
log.Printf("Creem Webhook签名验证成功")
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 验签成功 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
// 重新设置body供后续的ShouldBindJSON使用
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
@@ -265,19 +264,19 @@ func CreemWebhook(c *gin.Context) {
// 解析新格式的webhook数据
var webhookEvent CreemWebhookEvent
if err := c.ShouldBindJSON(&webhookEvent); err != nil {
log.Printf("解析Creem Webhook参数失败: %v", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem webhook 解析失败 path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), string(bodyBytes)))
c.AbortWithStatus(http.StatusBadRequest)
return
}
log.Printf("Creem Webhook解析成功 - EventType: %s, EventId: %s", webhookEvent.EventType, webhookEvent.Id)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 解析成功 event_type=%s event_id=%s request_id=%s order_id=%s order_status=%s", webhookEvent.EventType, webhookEvent.Id, webhookEvent.Object.RequestId, webhookEvent.Object.Order.Id, webhookEvent.Object.Order.Status))
// 根据事件类型处理不同的webhook
switch webhookEvent.EventType {
case "checkout.completed":
handleCheckoutCompleted(c, &webhookEvent)
default:
log.Printf("忽略Creem Webhook事件类型: %s", webhookEvent.EventType)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 忽略事件 event_type=%s event_id=%s", webhookEvent.EventType, webhookEvent.Id))
c.Status(http.StatusOK)
}
}
@@ -286,7 +285,7 @@ func CreemWebhook(c *gin.Context) {
func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
// 验证订单状态
if event.Object.Order.Status != "paid" {
log.Printf("订单状态不是已支付: %s, 跳过处理", event.Object.Order.Status)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 订单状态未支付,忽略处理 request_id=%s order_id=%s order_status=%s", event.Object.RequestId, event.Object.Order.Id, event.Object.Order.Status))
c.Status(http.StatusOK)
return
}
@@ -294,7 +293,7 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
// 获取引用ID(这是我们创建订单时传递的request_id)
referenceId := event.Object.RequestId
if referenceId == "" {
log.Println("Creem Webhook缺少request_id字段")
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 缺少 request_id event_id=%s order_id=%s", event.Id, event.Object.Order.Id))
c.AbortWithStatus(http.StatusBadRequest)
return
}
@@ -302,40 +301,35 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
// Try complete subscription order first
LockOrder(referenceId)
defer UnlockOrder(referenceId)
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(event)); err == nil {
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(event), model.PaymentMethodCreem); err == nil {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 订阅订单处理成功 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
c.Status(http.StatusOK)
return
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
log.Printf("Creem订阅订单处理失败: %s, 订单号: %s", err.Error(), referenceId)
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅订单处理失败 trade_no=%s creem_order_id=%s error=%q", referenceId, event.Object.Order.Id, err.Error()))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
// 验证订单类型,目前只处理一次性付款(充值)
if event.Object.Order.Type != "onetime" {
log.Printf("暂不支持订单类型: %s, 跳过处理", event.Object.Order.Type)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 暂不支持订单类型,忽略处理 request_id=%s creem_order_id=%s order_type=%s", referenceId, event.Object.Order.Id, event.Object.Order.Type))
c.Status(http.StatusOK)
return
}
// 记录详细的支付信息
log.Printf("处理Creem支付完成 - 订单号: %s, Creem订单ID: %s, 支付金额: %d %s, 客户邮箱: <redacted>, 产品: %s",
referenceId,
event.Object.Order.Id,
event.Object.Order.AmountPaid,
event.Object.Order.Currency,
event.Object.Product.Name)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 支付完成回调 trade_no=%s creem_order_id=%s amount_paid=%d currency=%s product_name=%q customer_email=%q customer_name=%q", referenceId, event.Object.Order.Id, event.Object.Order.AmountPaid, event.Object.Order.Currency, event.Object.Product.Name, event.Object.Customer.Email, event.Object.Customer.Name))
// 查询本地订单确认存在
topUp := model.GetTopUpByTradeNo(referenceId)
if topUp == nil {
log.Printf("Creem充值订单不存在: %s", referenceId)
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 充值订单不存在 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
c.AbortWithStatus(http.StatusBadRequest)
return
}
if topUp.Status != common.TopUpStatusPending {
log.Printf("Creem充值订单状态错误: %s, 当前状态: %s", referenceId, topUp.Status)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值订单状态非 pending,忽略处理 trade_no=%s status=%s creem_order_id=%s", referenceId, topUp.Status, event.Object.Order.Id))
c.Status(http.StatusOK) // 已处理过的订单,返回成功避免重复处理
return
}
@@ -346,21 +340,20 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
// 防护性检查,确保邮箱和姓名不为空字符串
if customerEmail == "" {
log.Printf("警告:Creem回调客户邮箱为空 - 订单号: %s", referenceId)
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 回调客户邮箱为空 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
}
if customerName == "" {
log.Printf("警告:Creem回调客户姓名为空 - 订单号: %s", referenceId)
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 回调客户姓名为空 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
}
err := model.RechargeCreem(referenceId, customerEmail, customerName)
err := model.RechargeCreem(referenceId, customerEmail, customerName, c.ClientIP())
if err != nil {
log.Printf("Creem充值处理失败: %s, 订单号: %s", err.Error(), referenceId)
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 充值处理失败 trade_no=%s creem_order_id=%s client_ip=%s error=%q", referenceId, event.Object.Order.Id, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
log.Printf("Creem充值成功 - 订单号: %s, 充值额度: %d, 支付金额: %.2f",
referenceId, topUp.Amount, topUp.Money)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值成功 trade_no=%s creem_order_id=%s quota=%d money=%.2f client_ip=%s", referenceId, event.Object.Order.Id, topUp.Amount, topUp.Money, c.ClientIP()))
c.Status(http.StatusOK)
}
@@ -378,7 +371,7 @@ type CreemCheckoutResponse struct {
Id string `json:"id"`
}
func genCreemLink(referenceId string, product *CreemProduct, email string, username string) (string, error) {
func genCreemLink(ctx context.Context, referenceId string, product *CreemProduct, email string, username string) (string, error) {
if setting.CreemApiKey == "" {
return "", fmt.Errorf("未配置Creem API密钥")
}
@@ -387,7 +380,7 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
apiUrl := "https://api.creem.io/v1/checkouts"
if setting.CreemTestMode {
apiUrl = "https://test-api.creem.io/v1/checkouts"
log.Printf("使用Creem测试环境: %s", apiUrl)
logger.LogInfo(ctx, fmt.Sprintf("Creem 使用测试环境 api_url=%s", apiUrl))
}
// 构建请求数据,确保包含用户邮箱
@@ -423,8 +416,7 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", setting.CreemApiKey)
log.Printf("发送Creem支付请求 - URL: %s, 产品ID: %s, 用户邮箱: %s, 订单号: %s",
apiUrl, product.ProductId, email, referenceId)
logger.LogInfo(ctx, fmt.Sprintf("Creem 支付请求已发送 api_url=%s product_id=%s email=%q trade_no=%s", apiUrl, product.ProductId, email, referenceId))
// 发送请求
client := &http.Client{
@@ -442,7 +434,7 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
return "", fmt.Errorf("读取响应失败: %v", err)
}
log.Printf("Creem API resp - status code: %d, resp: %s", resp.StatusCode, string(body))
logger.LogInfo(ctx, fmt.Sprintf("Creem API 响应已收到 trade_no=%s status_code=%d body=%q", referenceId, resp.StatusCode, string(body)))
// 检查响应状态
if resp.StatusCode/100 != 2 {
@@ -459,6 +451,6 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
return "", fmt.Errorf("Creem API resp no checkout url ")
}
log.Printf("Creem 支付链接创建成功 - 订单号: %s, 支付链接: %s", referenceId, checkoutResp.CheckoutUrl)
logger.LogInfo(ctx, fmt.Sprintf("Creem 支付链接创建成功 trade_no=%s response_id=%s checkout_url=%q", referenceId, checkoutResp.Id, checkoutResp.CheckoutUrl))
return checkoutResp.CheckoutUrl, nil
}
+31
View File
@@ -0,0 +1,31 @@
package controller
import (
"testing"
"github.com/QuantumNous/new-api/model"
)
func TestIsNonEpayPaymentMethodForEpayCallback(t *testing.T) {
testCases := []struct {
name string
paymentMethod string
expectedBlocked bool
}{
{name: "stripe", paymentMethod: model.PaymentMethodStripe, expectedBlocked: true},
{name: "creem", paymentMethod: model.PaymentMethodCreem, expectedBlocked: true},
{name: "waffo", paymentMethod: model.PaymentMethodWaffo, expectedBlocked: true},
{name: "waffo pancake", paymentMethod: model.PaymentMethodWaffoPancake, expectedBlocked: true},
{name: "alipay", paymentMethod: "alipay", expectedBlocked: false},
{name: "wxpay", paymentMethod: "wxpay", expectedBlocked: false},
{name: "custom epay type", paymentMethod: "custom1", expectedBlocked: false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if actual := isNonEpayPaymentMethodForEpayCallback(tc.paymentMethod); actual != tc.expectedBlocked {
t.Fatalf("expected blocked=%v, got %v for payment method %q", tc.expectedBlocked, actual, tc.paymentMethod)
}
})
}
}
+122 -52
View File
@@ -1,16 +1,17 @@
package controller
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
@@ -23,10 +24,6 @@ import (
"github.com/thanhpk/randstr"
)
const (
PaymentMethodStripe = "stripe"
)
var stripeAdaptor = &StripeAdaptor{}
// StripePayRequest represents a payment request for Stripe checkout.
@@ -48,34 +45,34 @@ type StripeAdaptor struct {
func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) {
if req.Amount < getStripeMinTopup() {
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())})
return
}
id := c.GetInt("id")
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getStripePayMoney(float64(req.Amount), group)
if payMoney <= 0.01 {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
return
}
c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
}
func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
if req.PaymentMethod != PaymentMethodStripe {
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
if req.PaymentMethod != model.PaymentMethodStripe {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付渠道"})
return
}
if req.Amount < getStripeMinTopup() {
c.JSON(200, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "data": 10})
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "data": 10})
return
}
if req.Amount > 10000 {
c.JSON(200, gin.H{"message": "充值数量不能大于 10000", "data": 10})
c.JSON(http.StatusOK, gin.H{"message": "充值数量不能大于 10000", "data": 10})
return
}
@@ -98,8 +95,8 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount, req.SuccessURL, req.CancelURL)
if err != nil {
log.Println("获取Stripe Checkout支付链接失败", err)
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 创建 Checkout Session 失败 user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
@@ -108,16 +105,18 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
Amount: req.Amount,
Money: chargedMoney,
TradeNo: referenceId,
PaymentMethod: PaymentMethodStripe,
PaymentMethod: model.PaymentMethodStripe,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
err = topUp.Insert()
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
c.JSON(200, gin.H{
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Stripe 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f", id, referenceId, req.Amount, chargedMoney))
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"pay_link": payLink,
@@ -129,7 +128,7 @@ func RequestStripeAmount(c *gin.Context) {
var req StripePayRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
stripeAdaptor.RequestAmount(c, &req)
@@ -139,54 +138,130 @@ func RequestStripePay(c *gin.Context) {
var req StripePayRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
stripeAdaptor.RequestPay(c, &req)
}
func StripeWebhook(c *gin.Context) {
ctx := c.Request.Context()
if !isStripeWebhookEnabled() {
logger.LogWarn(ctx, fmt.Sprintf("Stripe webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
c.AbortWithStatus(http.StatusForbidden)
return
}
payload, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("解析Stripe Webhook参数失败: %v\n", err)
logger.LogError(ctx, fmt.Sprintf("Stripe webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusServiceUnavailable)
return
}
signature := c.GetHeader("Stripe-Signature")
endpointSecret := setting.StripeWebhookSecret
event, err := webhook.ConstructEventWithOptions(payload, signature, endpointSecret, webhook.ConstructEventOptions{
logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(payload)))
event, err := webhook.ConstructEventWithOptions(payload, signature, setting.StripeWebhookSecret, webhook.ConstructEventOptions{
IgnoreAPIVersionMismatch: true,
})
if err != nil {
log.Printf("Stripe Webhook验签失败: %v\n", err)
logger.LogWarn(ctx, fmt.Sprintf("Stripe webhook 验签失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusBadRequest)
return
}
callerIp := c.ClientIP()
logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 验签成功 event_type=%s client_ip=%s path=%q", string(event.Type), callerIp, c.Request.RequestURI))
switch event.Type {
case stripe.EventTypeCheckoutSessionCompleted:
sessionCompleted(event)
sessionCompleted(ctx, event, callerIp)
case stripe.EventTypeCheckoutSessionExpired:
sessionExpired(event)
sessionExpired(ctx, event)
case stripe.EventTypeCheckoutSessionAsyncPaymentSucceeded:
sessionAsyncPaymentSucceeded(ctx, event, callerIp)
case stripe.EventTypeCheckoutSessionAsyncPaymentFailed:
sessionAsyncPaymentFailed(ctx, event, callerIp)
default:
log.Printf("不支持的Stripe Webhook事件类型: %s\n", event.Type)
logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 忽略事件 event_type=%s client_ip=%s", string(event.Type), callerIp))
}
c.Status(http.StatusOK)
}
func sessionCompleted(event stripe.Event) {
func sessionCompleted(ctx context.Context, event stripe.Event, callerIp string) {
customerId := event.GetObjectValue("customer")
referenceId := event.GetObjectValue("client_reference_id")
status := event.GetObjectValue("status")
if "complete" != status {
log.Println("错误的Stripe Checkout完成状态:", status, ",", referenceId)
logger.LogWarn(ctx, fmt.Sprintf("Stripe checkout.completed 状态异常,忽略处理 trade_no=%s status=%s client_ip=%s", referenceId, status, callerIp))
return
}
paymentStatus := event.GetObjectValue("payment_status")
if paymentStatus != "paid" {
logger.LogInfo(ctx, fmt.Sprintf("Stripe Checkout 支付未完成,等待异步结果 trade_no=%s payment_status=%s client_ip=%s", referenceId, paymentStatus, callerIp))
return
}
fulfillOrder(ctx, event, referenceId, customerId, callerIp)
}
// sessionAsyncPaymentSucceeded handles delayed payment methods (bank transfer, SEPA, etc.)
// that confirm payment after the checkout session completes.
func sessionAsyncPaymentSucceeded(ctx context.Context, event stripe.Event, callerIp string) {
customerId := event.GetObjectValue("customer")
referenceId := event.GetObjectValue("client_reference_id")
logger.LogInfo(ctx, fmt.Sprintf("Stripe 异步支付成功 trade_no=%s client_ip=%s", referenceId, callerIp))
fulfillOrder(ctx, event, referenceId, customerId, callerIp)
}
// sessionAsyncPaymentFailed marks orders as failed when delayed payment methods
// ultimately fail (e.g. bank transfer not received, SEPA rejected).
func sessionAsyncPaymentFailed(ctx context.Context, event stripe.Event, callerIp string) {
referenceId := event.GetObjectValue("client_reference_id")
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败 trade_no=%s client_ip=%s", referenceId, callerIp))
if len(referenceId) == 0 {
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败事件缺少订单号 client_ip=%s", callerIp))
return
}
LockOrder(referenceId)
defer UnlockOrder(referenceId)
topUp := model.GetTopUpByTradeNo(referenceId)
if topUp == nil {
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败但本地订单不存在 trade_no=%s client_ip=%s", referenceId, callerIp))
return
}
if topUp.PaymentMethod != model.PaymentMethodStripe {
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败但订单支付方式不匹配 trade_no=%s payment_method=%s client_ip=%s", referenceId, topUp.PaymentMethod, callerIp))
return
}
if topUp.Status != common.TopUpStatusPending {
logger.LogInfo(ctx, fmt.Sprintf("Stripe 异步支付失败但订单状态非 pending,忽略处理 trade_no=%s status=%s client_ip=%s", referenceId, topUp.Status, callerIp))
return
}
topUp.Status = common.TopUpStatusFailed
if err := topUp.Update(); err != nil {
logger.LogError(ctx, fmt.Sprintf("Stripe 标记充值订单失败状态失败 trade_no=%s client_ip=%s error=%q", referenceId, callerIp, err.Error()))
return
}
logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值订单已标记为失败 trade_no=%s client_ip=%s", referenceId, callerIp))
}
// fulfillOrder is the shared logic for crediting quota after payment is confirmed.
func fulfillOrder(ctx context.Context, event stripe.Event, referenceId string, customerId string, callerIp string) {
if len(referenceId) == 0 {
logger.LogWarn(ctx, fmt.Sprintf("Stripe 完成订单时缺少订单号 client_ip=%s", callerIp))
return
}
// Try complete subscription order first
LockOrder(referenceId)
defer UnlockOrder(referenceId)
payload := map[string]any{
@@ -195,65 +270,60 @@ func sessionCompleted(event stripe.Event) {
"currency": strings.ToUpper(event.GetObjectValue("currency")),
"event_type": string(event.Type),
}
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload)); err == nil {
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload), model.PaymentMethodStripe); err == nil {
logger.LogInfo(ctx, fmt.Sprintf("Stripe 订阅订单处理成功 trade_no=%s event_type=%s client_ip=%s", referenceId, string(event.Type), callerIp))
return
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
log.Println("complete subscription order failed:", err.Error(), referenceId)
logger.LogError(ctx, fmt.Sprintf("Stripe 订阅订单处理失败 trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
return
}
err := model.Recharge(referenceId, customerId)
err := model.Recharge(referenceId, customerId, callerIp)
if err != nil {
log.Println(err.Error(), referenceId)
logger.LogError(ctx, fmt.Sprintf("Stripe 充值处理失败 trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
return
}
total, _ := strconv.ParseFloat(event.GetObjectValue("amount_total"), 64)
currency := strings.ToUpper(event.GetObjectValue("currency"))
log.Printf("收到款项:%s, %.2f(%s)", referenceId, total/100, currency)
logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值成功 trade_no=%s amount_total=%.2f currency=%s event_type=%s client_ip=%s", referenceId, total/100, currency, string(event.Type), callerIp))
}
func sessionExpired(event stripe.Event) {
func sessionExpired(ctx context.Context, event stripe.Event) {
referenceId := event.GetObjectValue("client_reference_id")
status := event.GetObjectValue("status")
if "expired" != status {
log.Println("错误的Stripe Checkout过期状态:", status, ",", referenceId)
logger.LogWarn(ctx, fmt.Sprintf("Stripe checkout.expired 状态异常,忽略处理 trade_no=%s status=%s", referenceId, status))
return
}
if len(referenceId) == 0 {
log.Println("未提供支付单号")
logger.LogWarn(ctx, "Stripe checkout.expired 缺少订单号")
return
}
// Subscription order expiration
LockOrder(referenceId)
defer UnlockOrder(referenceId)
if err := model.ExpireSubscriptionOrder(referenceId); err == nil {
if err := model.ExpireSubscriptionOrder(referenceId, model.PaymentMethodStripe); err == nil {
logger.LogInfo(ctx, fmt.Sprintf("Stripe 订阅订单已过期 trade_no=%s", referenceId))
return
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
log.Println("过期订阅订单失败", referenceId, ", err:", err.Error())
logger.LogError(ctx, fmt.Sprintf("Stripe 订阅订单过期处理失败 trade_no=%s error=%q", referenceId, err.Error()))
return
}
topUp := model.GetTopUpByTradeNo(referenceId)
if topUp == nil {
log.Println("充值订单不存在", referenceId)
err := model.UpdatePendingTopUpStatus(referenceId, model.PaymentMethodStripe, common.TopUpStatusExpired)
if errors.Is(err, model.ErrTopUpNotFound) {
logger.LogWarn(ctx, fmt.Sprintf("Stripe 充值订单不存在,无法标记过期 trade_no=%s", referenceId))
return
}
if topUp.Status != common.TopUpStatusPending {
log.Println("充值订单状态错误", referenceId)
}
topUp.Status = common.TopUpStatusExpired
err := topUp.Update()
if err != nil {
log.Println("过期充值订单失败", referenceId, ", err:", err.Error())
logger.LogError(ctx, fmt.Sprintf("Stripe 充值订单过期处理失败 trade_no=%s error=%q", referenceId, err.Error()))
return
}
log.Println("充值订单已过期", referenceId)
logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值订单已过期 trade_no=%s", referenceId))
}
// genStripeLink generates a Stripe Checkout session URL for payment.
+73 -36
View File
@@ -1,14 +1,15 @@
package controller
import (
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
@@ -99,28 +100,57 @@ type WaffoPayRequest struct {
PayMethodName string `json:"pay_method_name"` // Deprecated: 兼容旧前端,优先使用 pay_method_index
}
func RequestWaffoAmount(c *gin.Context) {
var req WaffoPayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
waffoMinTopup := int64(setting.WaffoMinTopUp)
if req.Amount < waffoMinTopup {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
return
}
id := c.GetInt("id")
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getWaffoPayMoney(float64(req.Amount), group)
if payMoney <= 0.01 {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
}
// RequestWaffoPay 创建 Waffo 支付订单
func RequestWaffoPay(c *gin.Context) {
if !setting.WaffoEnabled {
c.JSON(200, gin.H{"message": "error", "data": "Waffo 支付未启用"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo 支付未启用"})
return
}
var req WaffoPayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
waffoMinTopup := int64(setting.WaffoMinTopUp)
if req.Amount < waffoMinTopup {
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
return
}
id := c.GetInt("id")
user, err := model.GetUserById(id, false)
if err != nil || user == nil {
c.JSON(200, gin.H{"message": "error", "data": "用户不存在"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
return
}
@@ -131,8 +161,8 @@ func RequestWaffoPay(c *gin.Context) {
// 新协议:按索引查找
idx := *req.PayMethodIndex
if idx < 0 || idx >= len(methods) {
log.Printf("Waffo 无效的支付方式索引: %d, UserId=%d, 可用范围: [0, %d)", idx, id, len(methods))
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付方式"})
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 支付方式索引无效 user_id=%d pay_method_index=%d method_count=%d", id, idx, len(methods)))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"})
return
}
resolvedPayMethodType = methods[idx].PayMethodType
@@ -149,8 +179,8 @@ func RequestWaffoPay(c *gin.Context) {
}
}
if !valid {
log.Printf("Waffo 无效的支付方式: PayMethodType=%s, PayMethodName=%s, UserId=%d", req.PayMethodType, req.PayMethodName, id)
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付方式"})
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 支付方式无效 user_id=%d pay_method_type=%s pay_method_name=%q", id, req.PayMethodType, req.PayMethodName))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"})
return
}
}
@@ -159,7 +189,7 @@ func RequestWaffoPay(c *gin.Context) {
group, _ := model.GetUserGroup(id, true)
payMoney := getWaffoPayMoney(float64(req.Amount), group)
if payMoney < 0.01 {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
return
}
@@ -182,22 +212,22 @@ func RequestWaffoPay(c *gin.Context) {
Amount: amount,
Money: payMoney,
TradeNo: merchantOrderId,
PaymentMethod: "waffo",
PaymentMethod: model.PaymentMethodWaffo,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
if err := topUp.Insert(); err != nil {
log.Printf("Waffo 创建本地订单失败: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, merchantOrderId, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
sdk, err := getWaffoSDK()
if err != nil {
log.Printf("Waffo SDK 初始化失败: %v", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo SDK 初始化失败 user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
c.JSON(200, gin.H{"message": "error", "data": "支付配置错误"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付配置错误"})
return
}
@@ -238,29 +268,29 @@ func RequestWaffoPay(c *gin.Context) {
}
resp, err := sdk.Order().Create(c.Request.Context(), createParams, nil)
if err != nil {
log.Printf("Waffo 创建订单失败: %v", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 创建订单失败 user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
if !resp.IsSuccess() {
log.Printf("Waffo 创建订单业务失败: [%s] %s, 完整响应: %+v", resp.Code, resp.Message, resp)
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 创建订单业务失败 user_id=%d trade_no=%s code=%s message=%q response=%q", id, merchantOrderId, resp.Code, resp.Message, common.GetJsonString(resp)))
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
orderData := resp.GetData()
log.Printf("Waffo 订单创建成功 - 用户: %d, 订单: %s, 金额: %.2f", id, merchantOrderId, payMoney)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f pay_method_type=%s pay_method_name=%q", id, merchantOrderId, req.Amount, payMoney, resolvedPayMethodType, resolvedPayMethodName))
paymentUrl := orderData.FetchRedirectURL()
if paymentUrl == "" {
paymentUrl = orderData.OrderAction
}
c.JSON(200, gin.H{
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"payment_url": paymentUrl,
@@ -287,16 +317,22 @@ type webhookSubscriptionInfo struct {
// WaffoWebhook 处理 Waffo 回调通知(支付/退款/订阅)
func WaffoWebhook(c *gin.Context) {
if !isWaffoWebhookEnabled() {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
c.AbortWithStatus(http.StatusForbidden)
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("Waffo Webhook 读取 body 失败: %v", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusBadRequest)
return
}
sdk, err := getWaffoSDK()
if err != nil {
log.Printf("Waffo Webhook SDK 初始化失败: %v", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook SDK 初始化失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
@@ -304,17 +340,18 @@ func WaffoWebhook(c *gin.Context) {
wh := sdk.Webhook()
bodyStr := string(bodyBytes)
signature := c.GetHeader("X-SIGNATURE")
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
// 验证请求签名
if !wh.VerifySignature(bodyStr, signature) {
log.Printf("Waffo webhook 签名验证失败")
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo webhook 验签失败 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
c.AbortWithStatus(http.StatusBadRequest)
return
}
var event core.WebhookEvent
if err := common.Unmarshal(bodyBytes, &event); err != nil {
log.Printf("Waffo Webhook 解析失败: %v", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook 解析失败 path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), bodyStr))
sendWaffoWebhookResponse(c, wh, false, "invalid payload")
return
}
@@ -324,14 +361,14 @@ func WaffoWebhook(c *gin.Context) {
// 解析为扩展类型,区分普通支付和订阅支付
var payload webhookPayloadWithSubInfo
if err := common.Unmarshal(bodyBytes, &payload); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 支付回调载荷解析失败 event_type=%s client_ip=%s error=%q body=%q", event.EventType, c.ClientIP(), err.Error(), bodyStr))
sendWaffoWebhookResponse(c, wh, false, "invalid payment payload")
return
}
log.Printf("Waffo Webhook - EventType: %s, MerchantOrderId: %s, OrderStatus: %s",
event.EventType, payload.Result.MerchantOrderID, payload.Result.OrderStatus)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 验签并解析成功 event_type=%s merchant_order_id=%s order_status=%s client_ip=%s", event.EventType, payload.Result.MerchantOrderID, payload.Result.OrderStatus, c.ClientIP()))
handleWaffoPayment(c, wh, &payload.Result.PaymentNotificationResult)
default:
log.Printf("Waffo Webhook 未知事件: %s", event.EventType)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 忽略事件 event_type=%s client_ip=%s", event.EventType, c.ClientIP()))
sendWaffoWebhookResponse(c, wh, true, "")
}
}
@@ -339,13 +376,13 @@ func WaffoWebhook(c *gin.Context) {
// handleWaffoPayment 处理支付完成通知
func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.PaymentNotificationResult) {
if result.OrderStatus != "PAY_SUCCESS" {
log.Printf("Waffo 订单状态非成功: %s, 订单: %s", result.OrderStatus, result.MerchantOrderID)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 订单状态非成功,忽略充值 trade_no=%s order_status=%s client_ip=%s", result.MerchantOrderID, result.OrderStatus, c.ClientIP()))
// 终态失败订单标记为 failed,避免永远停在 pending
if result.MerchantOrderID != "" {
if topUp := model.GetTopUpByTradeNo(result.MerchantOrderID); topUp != nil &&
topUp.Status == common.TopUpStatusPending {
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
if err := model.UpdatePendingTopUpStatus(result.MerchantOrderID, model.PaymentMethodWaffo, common.TopUpStatusFailed); err != nil &&
!errors.Is(err, model.ErrTopUpNotFound) &&
!errors.Is(err, model.ErrTopUpStatusInvalid) {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 标记失败订单状态失败 trade_no=%s error=%q", result.MerchantOrderID, err.Error()))
}
}
sendWaffoWebhookResponse(c, wh, true, "")
@@ -357,13 +394,13 @@ func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.Pa
LockOrder(merchantOrderId)
defer UnlockOrder(merchantOrderId)
if err := model.RechargeWaffo(merchantOrderId); err != nil {
log.Printf("Waffo 充值处理失败: %v, 订单: %s", err, merchantOrderId)
if err := model.RechargeWaffo(merchantOrderId, c.ClientIP()); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 充值处理失败 trade_no=%s client_ip=%s error=%q", merchantOrderId, c.ClientIP(), err.Error()))
sendWaffoWebhookResponse(c, wh, false, err.Error())
return
}
log.Printf("Waffo 充值成功 - 订单: %s", merchantOrderId)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 充值成功 trade_no=%s client_ip=%s", merchantOrderId, c.ClientIP()))
sendWaffoWebhookResponse(c, wh, true, "")
}
+259
View File
@@ -0,0 +1,259 @@
package controller
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"github.com/thanhpk/randstr"
)
type WaffoPancakePayRequest struct {
Amount int64 `json:"amount"`
}
func RequestWaffoPancakeAmount(c *gin.Context) {
var req WaffoPancakePayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
if req.Amount < int64(setting.WaffoPancakeMinTopUp) {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
return
}
id := c.GetInt("id")
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getWaffoPancakePayMoney(req.Amount, group)
if payMoney <= 0.01 {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "success", "data": fmt.Sprintf("%.2f", payMoney)})
}
func getWaffoPancakePayMoney(amount int64, group string) float64 {
dAmount := decimal.NewFromInt(amount)
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dAmount = dAmount.Div(decimal.NewFromFloat(common.QuotaPerUnit))
}
topupGroupRatio := common.GetTopupGroupRatio(group)
if topupGroupRatio == 0 {
topupGroupRatio = 1
}
discount := 1.0
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok && ds > 0 {
discount = ds
}
payMoney := dAmount.
Mul(decimal.NewFromFloat(setting.WaffoPancakeUnitPrice)).
Mul(decimal.NewFromFloat(topupGroupRatio)).
Mul(decimal.NewFromFloat(discount))
return payMoney.InexactFloat64()
}
func normalizeWaffoPancakeTopUpAmount(amount int64) int64 {
if operation_setting.GetQuotaDisplayType() != operation_setting.QuotaDisplayTypeTokens {
return amount
}
normalized := decimal.NewFromInt(amount).
Div(decimal.NewFromFloat(common.QuotaPerUnit)).
IntPart()
if normalized < 1 {
return 1
}
return normalized
}
func formatWaffoPancakeAmount(payMoney float64) string {
return decimal.NewFromFloat(payMoney).StringFixed(2)
}
func getWaffoPancakeBuyerEmail(user *model.User) string {
if user != nil && strings.TrimSpace(user.Email) != "" {
return user.Email
}
if user != nil {
return fmt.Sprintf("%d@new-api.local", user.Id)
}
return ""
}
func getWaffoPancakeReturnURL() string {
if strings.TrimSpace(setting.WaffoPancakeReturnURL) != "" {
return setting.WaffoPancakeReturnURL
}
return strings.TrimRight(system_setting.ServerAddress, "/") + "/console/topup?show_history=true"
}
func RequestWaffoPancakePay(c *gin.Context) {
if !setting.WaffoPancakeEnabled {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 支付未启用"})
return
}
currentWebhookKey := setting.WaffoPancakeWebhookPublicKey
if setting.WaffoPancakeSandbox {
currentWebhookKey = setting.WaffoPancakeWebhookTestKey
}
if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" ||
strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" ||
strings.TrimSpace(currentWebhookKey) == "" ||
strings.TrimSpace(setting.WaffoPancakeStoreID) == "" ||
strings.TrimSpace(setting.WaffoPancakeProductID) == "" {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 配置不完整"})
return
}
var req WaffoPancakePayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
if req.Amount < int64(setting.WaffoPancakeMinTopUp) {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
return
}
id := c.GetInt("id")
user, err := model.GetUserById(id, false)
if err != nil || user == nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
return
}
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getWaffoPancakePayMoney(req.Amount, group)
if payMoney < 0.01 {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
return
}
tradeNo := fmt.Sprintf("WAFFO_PANCAKE-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(6))
topUp := &model.TopUp{
UserId: id,
Amount: normalizeWaffoPancakeTopUpAmount(req.Amount),
Money: payMoney,
TradeNo: tradeNo,
PaymentMethod: model.PaymentMethodWaffoPancake,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
if err := topUp.Insert(); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, tradeNo, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
expiresInSeconds := 45 * 60
session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{
StoreID: setting.WaffoPancakeStoreID,
ProductID: setting.WaffoPancakeProductID,
ProductType: "onetime",
Currency: strings.ToUpper(strings.TrimSpace(setting.WaffoPancakeCurrency)),
PriceSnapshot: &service.WaffoPancakePriceSnapshot{
Amount: formatWaffoPancakeAmount(payMoney),
TaxIncluded: false,
TaxCategory: "saas",
},
BuyerEmail: getWaffoPancakeBuyerEmail(user),
SuccessURL: getWaffoPancakeReturnURL(),
ExpiresInSeconds: &expiresInSeconds,
})
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error()))
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值订单创建成功 user_id=%d trade_no=%s session_id=%s amount=%d money=%.2f", id, tradeNo, session.SessionID, req.Amount, payMoney))
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"checkout_url": session.CheckoutURL,
"session_id": session.SessionID,
"expires_at": session.ExpiresAt,
"order_id": tradeNo,
},
})
}
func WaffoPancakeWebhook(c *gin.Context) {
if !isWaffoPancakeWebhookEnabled() {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
c.String(http.StatusForbidden, "webhook disabled")
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.String(http.StatusBadRequest, "bad request")
return
}
signature := c.GetHeader("X-Waffo-Signature")
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
event, err := service.VerifyConfiguredWaffoPancakeWebhook(string(bodyBytes), signature)
if err != nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签失败 path=%q client_ip=%s signature=%q body=%q error=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes), err.Error()))
c.String(http.StatusUnauthorized, "invalid signature")
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签成功 event_type=%s event_id=%s order_id=%s client_ip=%s", event.NormalizedEventType(), event.ID, event.Data.OrderID, c.ClientIP()))
if event.NormalizedEventType() != "order.completed" {
c.String(http.StatusOK, "OK")
return
}
tradeNo, err := service.ResolveWaffoPancakeTradeNo(event)
if err != nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 订单号映射失败 event_id=%s order_id=%s error=%q", event.ID, event.Data.OrderID, err.Error()))
c.String(http.StatusOK, "OK")
return
}
LockOrder(tradeNo)
defer UnlockOrder(tradeNo)
if err := model.RechargeWaffoPancake(tradeNo); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值处理失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
c.String(http.StatusInternalServerError, "retry")
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值成功 trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
c.String(http.StatusOK, "OK")
}
+91
View File
@@ -0,0 +1,91 @@
package controller
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/stretchr/testify/require"
)
func TestFormatWaffoPancakeAmount_UsesDisplayPriceString(t *testing.T) {
testCases := []struct {
name string
amount float64
expected string
}{
{name: "whole amount", amount: 29, expected: "29.00"},
{name: "decimal amount", amount: 29.9, expected: "29.90"},
{name: "round half up to cents", amount: 29.999, expected: "30.00"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.expected, formatWaffoPancakeAmount(tc.amount))
})
}
}
func TestGetWaffoPancakePayMoney(t *testing.T) {
originalUnitPrice := setting.WaffoPancakeUnitPrice
originalQuotaDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType
originalDiscounts := make(map[int]float64, len(operation_setting.GetPaymentSetting().AmountDiscount))
for k, v := range operation_setting.GetPaymentSetting().AmountDiscount {
originalDiscounts[k] = v
}
originalTopupGroupRatio := common.TopupGroupRatio2JSONString()
t.Cleanup(func() {
setting.WaffoPancakeUnitPrice = originalUnitPrice
operation_setting.GetGeneralSetting().QuotaDisplayType = originalQuotaDisplayType
operation_setting.GetPaymentSetting().AmountDiscount = originalDiscounts
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(originalTopupGroupRatio))
})
setting.WaffoPancakeUnitPrice = 2.5
operation_setting.GetPaymentSetting().AmountDiscount = map[int]float64{
10: 0.8,
int(common.QuotaPerUnit * 3): 0.5,
20: 0,
}
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(`{"default":1,"vip":1.2}`))
testCases := []struct {
name string
amount int64
group string
quotaDisplayType string
expected float64
}{
{
name: "currency display applies unit price group ratio and discount",
amount: 10,
group: "vip",
quotaDisplayType: operation_setting.QuotaDisplayTypeUSD,
expected: 24,
},
{
name: "tokens display converts quota to display units before pricing",
amount: int64(common.QuotaPerUnit * 3),
group: "vip",
quotaDisplayType: operation_setting.QuotaDisplayTypeTokens,
expected: 4.5,
},
{
name: "non-positive discount falls back to no discount",
amount: 20,
group: "default",
quotaDisplayType: operation_setting.QuotaDisplayTypeUSD,
expected: 50,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
operation_setting.GetGeneralSetting().QuotaDisplayType = tc.quotaDisplayType
actual := getWaffoPancakePayMoney(tc.amount, tc.group)
require.InDelta(t, tc.expected, actual, 0.000001)
})
}
}
+8 -4
View File
@@ -2,7 +2,6 @@ package controller
import (
"errors"
"fmt"
"net/http"
"strconv"
@@ -542,10 +541,15 @@ func AdminDisable2FA(c *gin.Context) {
return
}
// 记录操作日志
// 记录操作日志:管理员身份通过 admin_info 传递,避免在非管理员可见的日志内容中泄露。
adminId := c.GetInt("id")
model.RecordLog(userId, model.LogTypeManage,
fmt.Sprintf("管理员(ID:%d)强制禁用了用户的两步验证", adminId))
adminName := c.GetString("username")
adminInfo := map[string]interface{}{
"admin_id": adminId,
"admin_username": adminName,
}
model.RecordLogWithAdminInfo(userId, model.LogTypeManage,
"管理员强制禁用了用户的两步验证", adminInfo)
c.JSON(http.StatusOK, gin.H{
"success": true,
+15
View File
@@ -27,6 +27,21 @@ func GetAllQuotaDates(c *gin.Context) {
return
}
func GetQuotaDatesByUser(c *gin.Context) {
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
dates, err := model.GetQuotaDataGroupByUser(startTimestamp, endTimestamp)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": dates,
})
}
func GetUserQuotaDates(c *gin.Context) {
userId := c.GetInt("id")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
+75 -7
View File
@@ -52,10 +52,15 @@ func Login(c *gin.Context) {
}
err = user.ValidateAndFill()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": err.Error(),
"success": false,
})
switch {
case errors.Is(err, model.ErrDatabase):
common.SysLog(fmt.Sprintf("Login database error for user %s: %v", username, err))
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
case errors.Is(err, model.ErrUserEmptyCredentials):
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
default:
common.ApiErrorI18n(c, i18n.MsgUserUsernameOrPasswordError)
}
return
}
@@ -572,9 +577,6 @@ func UpdateUser(c *gin.Context) {
common.ApiError(c, err)
return
}
if originUser.Quota != updatedUser.Quota {
model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", logger.LogQuota(originUser.Quota), logger.LogQuota(updatedUser.Quota)))
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -841,6 +843,8 @@ func CreateUser(c *gin.Context) {
type ManageRequest struct {
Id int `json:"id"`
Action string `json:"action"`
Value int `json:"value"`
Mode string `json:"mode"`
}
// ManageUser Only admin user can do this
@@ -887,6 +891,11 @@ func ManageUser(c *gin.Context) {
})
return
}
// 删除用户后,强制清理 Redis 中所有该用户令牌的缓存,
// 避免已缓存的令牌在 TTL 过期前仍能通过 TokenAuth 校验。
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
}
case "promote":
if myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote)
@@ -907,12 +916,71 @@ func ManageUser(c *gin.Context) {
return
}
user.Role = common.RoleCommonUser
case "add_quota":
adminName := c.GetString("username")
adminId := c.GetInt("id")
adminInfo := map[string]interface{}{
"admin_id": adminId,
"admin_username": adminName,
}
switch req.Mode {
case "add":
if req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
return
}
if err := model.IncreaseUserQuota(user.Id, req.Value, true); err != nil {
common.ApiError(c, err)
return
}
model.RecordLogWithAdminInfo(user.Id, model.LogTypeManage,
fmt.Sprintf("管理员增加用户额度 %s", logger.LogQuota(req.Value)), adminInfo)
case "subtract":
if req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
return
}
if err := model.DecreaseUserQuota(user.Id, req.Value, true); err != nil {
common.ApiError(c, err)
return
}
model.RecordLogWithAdminInfo(user.Id, model.LogTypeManage,
fmt.Sprintf("管理员减少用户额度 %s", logger.LogQuota(req.Value)), adminInfo)
case "override":
oldQuota := user.Quota
if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil {
common.ApiError(c, err)
return
}
model.RecordLogWithAdminInfo(user.Id, model.LogTypeManage,
fmt.Sprintf("管理员覆盖用户额度从 %s 为 %s", logger.LogQuota(oldQuota), logger.LogQuota(req.Value)), adminInfo)
default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
if err := user.Update(false); err != nil {
common.ApiError(c, err)
return
}
// 禁用 / 角色调整后,强制失效用户缓存与其全部令牌缓存,
// 避免在 Redis TTL 过期前仍使用旧状态(尤其是禁用后仍可发起请求的问题)。
// InvalidateUserCache 会让下一次 GetUserCache 从数据库重新加载,
// InvalidateUserTokensCache 则确保令牌侧的缓存也同步刷新。
if req.Action == "disable" || req.Action == "promote" || req.Action == "demote" {
if err := model.InvalidateUserCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate user cache for user %d: %s", user.Id, err.Error()))
}
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
}
}
clearUser := model.User{
Role: user.Role,
Status: user.Status,
+3 -1
View File
@@ -28,10 +28,11 @@ services:
environment:
- SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production!
# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL
- REDIS_CONN_STRING=redis://redis
- REDIS_CONN_STRING=redis://:123456@redis:6379 # ⚠️ IMPORTANT: Change the password in production!
- TZ=Asia/Shanghai
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording)
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update)
- NODE_NAME=new-api-node-1 # 节点名称,用于审计日志中标识节点身份;多节点/容器部署时建议设置 (Node name used in audit logs; recommended when running multiple instances or in containers)
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
@@ -55,6 +56,7 @@ services:
image: redis:latest
container_name: redis
restart: always
command: ["redis-server", "--requirepass", "123456"] # ⚠️ IMPORTANT: Change this password in production!
networks:
- new-api-network
+53 -1
View File
@@ -3281,6 +3281,13 @@
}
]
},
"cache_control": {
"type": "object",
"properties": {}
},
"inference_geo": {
"type": "string"
},
"max_tokens": {
"type": "integer",
"minimum": 1
@@ -3333,7 +3340,8 @@
"enum": [
"auto",
"any",
"tool"
"tool",
"none"
]
},
"name": {
@@ -3358,6 +3366,36 @@
}
}
},
"context_management": {
"type": "object",
"properties": {}
},
"output_config": {
"type": "object",
"properties": {}
},
"output_format": {
"type": "object",
"properties": {}
},
"container": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {}
}
]
},
"mcp_servers": {
"type": "array",
"items": {
"type": "object",
"properties": {}
}
},
"metadata": {
"type": "object",
"properties": {
@@ -3365,6 +3403,20 @@
"type": "string"
}
}
},
"speed": {
"type": "string",
"enum": [
"standard",
"fast"
]
},
"service_tier": {
"type": "string",
"enum": [
"auto",
"standard_only"
]
}
}
},
+10
View File
@@ -18,6 +18,16 @@ type AudioRequest struct {
Speed *float64 `json:"speed,omitempty"`
StreamFormat string `json:"stream_format,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
// vllm-omini
TaskType json.RawMessage `json:"task_type,omitempty"`
Language json.RawMessage `json:"language,omitempty"`
RefAudio json.RawMessage `json:"ref_audio,omitempty"`
RefText json.RawMessage `json:"ref_text,omitempty"`
XVectorOnlyMode json.RawMessage `json:"x_vector_only_mode,omitempty"`
MaxNewTokens json.RawMessage `json:"max_new_tokens,omitempty"`
InitialCodecChunkFrames json.RawMessage `json:"initial_codec_chunk_frames,omitempty"`
// TODOensure that the logic remains correct after the stream is started.
//Stream json.RawMessage `json:"stream,omitempty"`
}
func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {
+1
View File
@@ -30,6 +30,7 @@ type ChannelOtherSettings struct {
ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规
AllowSpeed bool `json:"allow_speed,omitempty"` // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式)
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
+37 -34
View File
@@ -98,6 +98,20 @@ func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage {
return mediaContent
}
func (m *ClaudeMediaMessage) ToFileSource() types.FileSource {
if m.Source == nil {
return nil
}
data := m.Source.Url
if data == "" {
data = common.Interface2String(m.Source.Data)
}
if data == "" {
return nil
}
return types.NewFileSourceFromData(data, m.Source.MediaType)
}
type ClaudeMessageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type,omitempty"`
@@ -190,10 +204,11 @@ type ClaudeToolChoice struct {
}
type ClaudeRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt,omitempty"`
System any `json:"system,omitempty"`
Messages []ClaudeMessage `json:"messages,omitempty"`
Model string `json:"model"`
Prompt string `json:"prompt,omitempty"`
System any `json:"system,omitempty"`
Messages []ClaudeMessage `json:"messages,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
// InferenceGeo controls Claude data residency region.
// This field is filtered by default and can be enabled via channel setting allow_inference_geo.
InferenceGeo string `json:"inference_geo,omitempty"`
@@ -213,6 +228,9 @@ type ClaudeRequest struct {
Thinking *Thinking `json:"thinking,omitempty"`
McpServers json.RawMessage `json:"mcp_servers,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
// Speed specifies the Claude inference speed mode.
// This field is filtered by default and can be enabled via channel setting allow_speed.
Speed json.RawMessage `json:"speed,omitempty"`
// ServiceTier specifies upstream service level and may affect billing.
// This field is filtered by default and can be enabled via channel setting allow_service_tier.
ServiceTier string `json:"service_tier,omitempty"`
@@ -223,14 +241,6 @@ type OutputConfigForEffort struct {
Effort string `json:"effort,omitempty"`
}
// createClaudeFileSource 根据数据内容创建正确类型的 FileSource
func createClaudeFileSource(data string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return types.NewURLFileSource(data)
}
return types.NewBase64FileSource(data, "")
}
func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
maxTokens := 0
if c.MaxTokens != nil {
@@ -258,17 +268,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
case "text":
texts = append(texts, media.GetText())
case "image":
if media.Source != nil {
data := media.Source.Url
if data == "" {
data = common.Interface2String(media.Source.Data)
}
if data != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createClaudeFileSource(data),
})
}
if source := media.ToFileSource(); source != nil {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: source,
})
}
}
}
@@ -293,17 +297,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
case "text":
texts = append(texts, media.GetText())
case "image":
if media.Source != nil {
data := media.Source.Url
if data == "" {
data = common.Interface2String(media.Source.Data)
}
if data != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createClaudeFileSource(data),
})
}
if source := media.ToFileSource(); source != nil {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: source,
})
}
case "tool_use":
if media.Name != "" {
@@ -450,6 +448,11 @@ func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) {
type Thinking struct {
Type string `json:"type,omitempty"`
BudgetTokens *int `json:"budget_tokens,omitempty"`
// Display controls whether thinking content is returned in the response.
// Used with adaptive thinking on Claude Opus 4.7+: "summarized" restores
// the visible summary that was default on Opus 4.6; "omitted" (default on
// 4.7) suppresses it. Pass-through field from upstream Anthropic API.
Display string `json:"display,omitempty"`
}
func (c *Thinking) GetBudgetTokens() int {
+14 -11
View File
@@ -46,6 +46,7 @@ func (r *GeminiChatRequest) UnmarshalJSON(data []byte) error {
type ToolConfig struct {
FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"`
IncludeServerSideToolInvocations *bool `json:"includeServerSideToolInvocations,omitempty"`
}
type FunctionCallingConfig struct {
@@ -64,14 +65,6 @@ type LatLng struct {
Longitude *float64 `json:"longitude,omitempty"`
}
// createGeminiFileSource 根据数据内容创建正确类型的 FileSource
func createGeminiFileSource(data string, mimeType string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return types.NewURLFileSource(data)
}
return types.NewBase64FileSource(data, mimeType)
}
func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
var files []*types.FileMeta = make([]*types.FileMeta, 0)
@@ -87,9 +80,8 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
if part.Text != "" {
inputTexts = append(inputTexts, part.Text)
}
if part.InlineData != nil && part.InlineData.Data != "" {
if source := part.InlineData.ToFileSource(); source != nil {
mimeType := part.InlineData.MimeType
source := createGeminiFileSource(part.InlineData.Data, mimeType)
var fileType types.FileType
if strings.HasPrefix(mimeType, "image/") {
fileType = types.FileTypeImage
@@ -103,7 +95,6 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
files = append(files, &types.FileMeta{
FileType: fileType,
Source: source,
MimeType: mimeType,
})
}
}
@@ -121,6 +112,11 @@ func (r *GeminiChatRequest) IsStream(c *gin.Context) bool {
if c.Query("alt") == "sse" {
return true
}
// Native Gemini API uses URL action to indicate streaming:
// /v1beta/models/{model}:streamGenerateContent
if strings.Contains(c.Request.URL.Path, "streamGenerateContent") {
return true
}
return false
}
@@ -210,6 +206,13 @@ type GeminiInlineData struct {
Data string `json:"data"`
}
func (d *GeminiInlineData) ToFileSource() types.FileSource {
if d == nil || d.Data == "" {
return nil
}
return types.NewFileSourceFromData(d.Data, d.MimeType)
}
// UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType
func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
type Alias GeminiInlineData // Use type alias to avoid recursion
+73
View File
@@ -0,0 +1,73 @@
package dto
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestGeminiChatRequest_IsStream(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
path string
query string
expected bool
}{
{
name: "streamGenerateContent without alt=sse",
path: "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
query: "key=sk-xxx",
expected: true,
},
{
name: "streamGenerateContent with alt=sse",
path: "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
query: "alt=sse&key=sk-xxx",
expected: true,
},
{
name: "generateContent without alt=sse",
path: "/v1beta/models/gemini-2.0-flash:generateContent",
query: "key=sk-xxx",
expected: false,
},
{
name: "generateContent with alt=sse",
path: "/v1beta/models/gemini-2.0-flash:generateContent",
query: "alt=sse",
expected: true,
},
{
name: "GenerateContent capitalized",
path: "/v1beta/models/gemini-2.0-flash:GenerateContent",
query: "key=sk-xxx",
expected: false,
},
{
name: "embedding path",
path: "/v1beta/models/gemini-2.0-flash:embedContent",
query: "",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
url := tt.path
if tt.query != "" {
url += "?" + tt.query
}
c.Request, _ = http.NewRequest("POST", url, nil)
req := &GeminiChatRequest{}
assert.Equal(t, tt.expected, req.IsStream(c))
})
}
}
+53 -47
View File
@@ -108,14 +108,6 @@ type GeneralOpenAIRequest struct {
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
}
// createFileSource 根据数据内容创建正确类型的 FileSource
func createFileSource(data string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return types.NewURLFileSource(data)
}
return types.NewBase64FileSource(data, "")
}
func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
var tokenCountMeta types.TokenCountMeta
var texts = make([]string, 0)
@@ -159,44 +151,24 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
}
arrayContent := message.ParseContent()
for _, m := range arrayContent {
if m.Type == ContentTypeImageURL {
imageUrl := m.GetImageMedia()
if imageUrl != nil && imageUrl.Url != "" {
source := createFileSource(imageUrl.Url)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: source,
Detail: imageUrl.Detail,
})
source := m.ToFileSource()
if source != nil {
meta := &types.FileMeta{Source: source}
switch m.Type {
case ContentTypeImageURL:
meta.FileType = types.FileTypeImage
if img := m.GetImageMedia(); img != nil {
meta.Detail = img.Detail
}
case ContentTypeInputAudio:
meta.FileType = types.FileTypeAudio
case ContentTypeFile:
meta.FileType = types.FileTypeFile
case ContentTypeVideoUrl:
meta.FileType = types.FileTypeVideo
}
} else if m.Type == ContentTypeInputAudio {
inputAudio := m.GetInputAudio()
if inputAudio != nil && inputAudio.Data != "" {
source := createFileSource(inputAudio.Data)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeAudio,
Source: source,
})
}
} else if m.Type == ContentTypeFile {
file := m.GetFile()
if file != nil && file.FileData != "" {
source := createFileSource(file.FileData)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeFile,
Source: source,
})
}
} else if m.Type == ContentTypeVideoUrl {
videoUrl := m.GetVideoUrl()
if videoUrl != nil && videoUrl.Url != "" {
source := createFileSource(videoUrl.Url)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeVideo,
Source: source,
})
}
} else {
fileMeta = append(fileMeta, meta)
} else if m.Type == ContentTypeText {
texts = append(texts, m.Text)
}
}
@@ -391,6 +363,40 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl {
return nil
}
func (m *MediaContent) ToFileSource() types.FileSource {
switch m.Type {
case ContentTypeImageURL:
img := m.GetImageMedia()
if img == nil || img.Url == "" {
return nil
}
return types.NewFileSourceFromData(img.Url, img.MimeType)
case ContentTypeInputAudio:
audio := m.GetInputAudio()
if audio == nil || audio.Data == "" {
return nil
}
mimeType := ""
if audio.Format != "" {
mimeType = "audio/" + audio.Format
}
return types.NewFileSourceFromData(audio.Data, mimeType)
case ContentTypeFile:
file := m.GetFile()
if file == nil || file.FileData == "" {
return nil
}
return types.NewFileSourceFromData(file.FileData, "")
case ContentTypeVideoUrl:
video := m.GetVideoUrl()
if video == nil || video.Url == "" {
return nil
}
return types.NewFileSourceFromData(video.Url, "")
}
return nil
}
type MessageImageUrl struct {
Url string `json:"url"`
Detail string `json:"detail,omitempty"`
@@ -865,7 +871,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
if input.ImageUrl != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createFileSource(input.ImageUrl),
Source: types.NewFileSourceFromData(input.ImageUrl, ""),
Detail: input.Detail,
})
}
@@ -873,7 +879,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
if input.FileUrl != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeFile,
Source: createFileSource(input.FileUrl),
Source: types.NewFileSourceFromData(input.FileUrl, ""),
})
}
} else {
+1 -1
View File
@@ -272,7 +272,7 @@ type OpenAIResponsesResponse struct {
Status json.RawMessage `json:"status"`
Error any `json:"error,omitempty"`
IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"`
Instructions string `json:"instructions"`
Instructions json.RawMessage `json:"instructions"`
MaxOutputTokens int `json:"max_output_tokens"`
Model string `json:"model"`
Output []ResponsesOutput `json:"output"`
+22
View File
@@ -5,6 +5,28 @@ import (
"strconv"
)
type StringValue string
func (s *StringValue) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err == nil {
*s = StringValue(str)
return nil
}
var raw json.Number
if err := json.Unmarshal(data, &raw); err == nil {
*s = StringValue(raw.String())
return nil
}
return json.Unmarshal(data, &str)
}
func (s StringValue) MarshalJSON() ([]byte, error) {
return json.Marshal(string(s))
}
type IntValue int
func (i *IntValue) UnmarshalJSON(b []byte) error {
Generated Vendored
+10 -10
View File
@@ -9,7 +9,7 @@
"version": "1.0.0",
"devDependencies": {
"cross-env": "^7.0.3",
"electron": "35.7.5",
"electron": "39.8.5",
"electron-builder": "^26.7.0"
}
},
@@ -777,9 +777,9 @@
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2145,9 +2145,9 @@
}
},
"node_modules/electron": {
"version": "35.7.5",
"resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz",
"integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==",
"version": "39.8.5",
"resolved": "https://registry.npmjs.org/electron/-/electron-39.8.5.tgz",
"integrity": "sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -3279,9 +3279,9 @@
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
+1 -1
View File
@@ -25,7 +25,7 @@
},
"devDependencies": {
"cross-env": "^7.0.3",
"electron": "35.7.5",
"electron": "39.8.5",
"electron-builder": "^26.7.0"
},
"build": {
+6 -6
View File
@@ -8,9 +8,9 @@ require (
github.com/abema/go-mp4 v1.4.1
github.com/andybalholm/brotli v1.1.1
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
github.com/aws/aws-sdk-go-v2 v1.41.2
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
github.com/aws/smithy-go v1.24.2
github.com/bytedance/gopkg v0.1.3
github.com/gin-contrib/cors v1.7.2
@@ -63,9 +63,9 @@ require (
require (
github.com/DmitriyVTitov/size v1.5.0 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
@@ -96,7 +96,7 @@ require (
github.com/icza/bitio v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.1 // indirect
github.com/jackc/pgx/v5 v5.9.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jfreymuth/vorbis v1.0.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
+12 -12
View File
@@ -12,18 +12,18 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0 h1:TDKR8ACRw7G+GFaQlhoy6biu+8q6ZtSddQCy9avMdMI=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0/go.mod h1:XlhOh5Ax/lesqN4aZCUgj9vVJed5VoXYHHFYGAlJEwU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -152,8 +152,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/pgx/v5 v5.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE=
github.com/jackc/pgx/v5 v5.9.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ=
+14
View File
@@ -25,6 +25,19 @@ const (
MsgDeleteFailed = "common.delete_failed"
MsgAlreadyExists = "common.already_exists"
MsgNameCannotBeEmpty = "common.name_cannot_be_empty"
MsgBatchTooMany = "common.batch_too_many"
)
// Auth middleware messages
const (
MsgAuthNotLoggedIn = "auth.not_logged_in"
MsgAuthAccessTokenInvalid = "auth.access_token_invalid"
MsgAuthUserInfoInvalid = "auth.user_info_invalid"
MsgAuthUserIdNotProvided = "auth.user_id_not_provided"
MsgAuthUserIdFormatError = "auth.user_id_format_error"
MsgAuthUserIdMismatch = "auth.user_id_mismatch"
MsgAuthUserBanned = "auth.user_banned"
MsgAuthInsufficientPrivilege = "auth.insufficient_privilege"
)
// Token related messages
@@ -100,6 +113,7 @@ const (
MsgUserTelegramIdEmpty = "user.telegram_id_empty"
MsgUserTelegramNotBound = "user.telegram_not_bound"
MsgUserLinuxDOIdEmpty = "user.linux_do_id_empty"
MsgUserQuotaChangeZero = "user.quota_change_zero"
)
// Quota related messages
+13 -1
View File
@@ -2,7 +2,7 @@
# Common messages
common.invalid_params: "Invalid parameters"
common.database_error: "Database error, please try again later"
common.database_error: "Database error, please contact the administrator"
common.retry_later: "Please try again later"
common.generate_failed: "Generation failed"
common.not_found: "Not found"
@@ -21,6 +21,17 @@ common.delete_success: "Deletion successful"
common.delete_failed: "Deletion failed"
common.already_exists: "Already exists"
common.name_cannot_be_empty: "Name cannot be empty"
common.batch_too_many: "Too many items in batch request, maximum is {{.Max}}"
# Auth middleware messages
auth.not_logged_in: "Unauthorized, not logged in and no access token provided"
auth.access_token_invalid: "Unauthorized, invalid access token"
auth.user_info_invalid: "Unauthorized, invalid user info"
auth.user_id_not_provided: "Unauthorized, New-Api-User header not provided"
auth.user_id_format_error: "Unauthorized, New-Api-User header format error"
auth.user_id_mismatch: "Unauthorized, New-Api-User does not match logged in user"
auth.user_banned: "User has been banned"
auth.insufficient_privilege: "Unauthorized, insufficient privileges"
# Token messages
token.name_too_long: "Token name is too long"
@@ -90,6 +101,7 @@ user.wechat_id_empty: "WeChat ID is empty!"
user.telegram_id_empty: "Telegram ID is empty!"
user.telegram_not_bound: "This Telegram account is not bound"
user.linux_do_id_empty: "Linux DO ID is empty!"
user.quota_change_zero: "Quota change amount cannot be zero"
# Quota messages
quota.negative: "Quota cannot be negative!"
+13 -1
View File
@@ -3,7 +3,7 @@
# Common messages
common.invalid_params: "无效的参数"
common.database_error: "数据库错误,请稍后重试"
common.database_error: "数据库出错,请联系管理员"
common.retry_later: "请稍后重试"
common.generate_failed: "生成失败"
common.not_found: "未找到"
@@ -22,6 +22,17 @@ common.delete_success: "删除成功"
common.delete_failed: "删除失败"
common.already_exists: "已存在"
common.name_cannot_be_empty: "名称不能为空"
common.batch_too_many: "批量请求数量过多,最多 {{.Max}} 条"
# Auth middleware messages
auth.not_logged_in: "无权进行此操作,未登录且未提供 access token"
auth.access_token_invalid: "无权进行此操作,access token 无效"
auth.user_info_invalid: "无权进行此操作,用户信息无效"
auth.user_id_not_provided: "无权进行此操作,未提供 New-Api-User"
auth.user_id_format_error: "无权进行此操作,New-Api-User 格式错误"
auth.user_id_mismatch: "无权进行此操作,New-Api-User 与登录用户不匹配"
auth.user_banned: "用户已被封禁"
auth.insufficient_privilege: "无权进行此操作,权限不足"
# Token messages
token.name_too_long: "令牌名称过长"
@@ -91,6 +102,7 @@ user.wechat_id_empty: "WeChat id 为空!"
user.telegram_id_empty: "Telegram id 为空!"
user.telegram_not_bound: "该 Telegram 账户未绑定"
user.linux_do_id_empty: "Linux DO id 为空!"
user.quota_change_zero: "额度变更量不能为0"
# Quota messages
quota.negative: "额度不能为负数!"
+13 -1
View File
@@ -3,7 +3,7 @@
# Common messages
common.invalid_params: "無效的參數"
common.database_error: "資料庫錯誤,請稍後重試"
common.database_error: "資料庫出錯,請聯繫管理員"
common.retry_later: "請稍後重試"
common.generate_failed: "生成失敗"
common.not_found: "未找到"
@@ -22,6 +22,17 @@ common.delete_success: "刪除成功"
common.delete_failed: "刪除失敗"
common.already_exists: "已存在"
common.name_cannot_be_empty: "名稱不能為空"
common.batch_too_many: "批次請求數量過多,最多 {{.Max}} 條"
# Auth middleware messages
auth.not_logged_in: "無權進行此操作,未登入且未提供 access token"
auth.access_token_invalid: "無權進行此操作,access token 無效"
auth.user_info_invalid: "無權進行此操作,使用者資訊無效"
auth.user_id_not_provided: "無權進行此操作,未提供 New-Api-User"
auth.user_id_format_error: "無權進行此操作,New-Api-User 格式錯誤"
auth.user_id_mismatch: "無權進行此操作,New-Api-User 與登入使用者不匹配"
auth.user_banned: "使用者已被封禁"
auth.insufficient_privilege: "無權進行此操作,權限不足"
# Token messages
token.name_too_long: "令牌名稱過長"
@@ -91,6 +102,7 @@ user.wechat_id_empty: "WeChat id 為空!"
user.telegram_id_empty: "Telegram id 為空!"
user.telegram_not_bound: "該 Telegram 帳號未綁定"
user.linux_do_id_empty: "Linux DO id 為空!"
user.quota_change_zero: "額度變更量不能為0"
# Quota messages
quota.negative: "額度不能為負數!"
+57 -20
View File
@@ -1,6 +1,7 @@
package middleware
import (
"errors"
"fmt"
"net"
"net/http"
@@ -9,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
@@ -17,6 +19,7 @@ import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func validUserInfo(username string, role int) bool {
@@ -43,17 +46,33 @@ func authHelper(c *gin.Context, minRole int) {
if accessToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,未登录且未提供 access token",
"message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn),
})
c.Abort()
return
}
user := model.ValidateAccessToken(accessToken)
user, authErr := model.ValidateAccessToken(accessToken)
if authErr != nil {
if errors.Is(authErr, model.ErrDatabase) {
common.SysLog("ValidateAccessToken database error: " + authErr.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
} else {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
})
}
c.Abort()
return
}
if user != nil && user.Username != "" {
if !validUserInfo(user.Username, user.Role) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作,用户信息无效",
"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
})
c.Abort()
return
@@ -67,7 +86,7 @@ func authHelper(c *gin.Context, minRole int) {
} else {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作,access token 无效",
"message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
})
c.Abort()
return
@@ -78,7 +97,7 @@ func authHelper(c *gin.Context, minRole int) {
if apiUserIdStr == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,未提供 New-Api-User",
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdNotProvided),
})
c.Abort()
return
@@ -87,7 +106,7 @@ func authHelper(c *gin.Context, minRole int) {
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,New-Api-User 格式错误",
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdFormatError),
})
c.Abort()
return
@@ -96,7 +115,7 @@ func authHelper(c *gin.Context, minRole int) {
if id != apiUserId {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,New-Api-User 与登录用户不匹配",
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdMismatch),
})
c.Abort()
return
@@ -104,7 +123,7 @@ func authHelper(c *gin.Context, minRole int) {
if status.(int) == common.UserStatusDisabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户已被封禁",
"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
})
c.Abort()
return
@@ -112,7 +131,7 @@ func authHelper(c *gin.Context, minRole int) {
if role.(int) < minRole {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作,权限不足",
"message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege),
})
c.Abort()
return
@@ -120,7 +139,7 @@ func authHelper(c *gin.Context, minRole int) {
if !validUserInfo(username.(string), role.(int)) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作,用户信息无效",
"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
})
c.Abort()
return
@@ -198,7 +217,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
if key == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未提供 Authorization 请求头",
"message": common.TranslateMessage(c, i18n.MsgTokenNotProvided),
})
c.Abort()
return
@@ -212,19 +231,28 @@ func TokenAuthReadOnly() func(c *gin.Context) {
token, err := model.GetTokenByKey(key, false)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无效的令牌",
})
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgTokenInvalid),
})
} else {
common.SysLog("TokenAuthReadOnly GetTokenByKey database error: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
}
c.Abort()
return
}
userCache, err := model.GetUserCache(token.UserId)
if err != nil {
common.SysLog(fmt.Sprintf("TokenAuthReadOnly GetUserCache error for user %d: %v", token.UserId, err))
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": err.Error(),
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
c.Abort()
return
@@ -232,7 +260,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
if userCache.Status != common.UserStatusEnabled {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "用户已被封禁",
"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
})
c.Abort()
return
@@ -309,7 +337,14 @@ func TokenAuth() func(c *gin.Context) {
}
}
if err != nil {
abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error())
if errors.Is(err, model.ErrDatabase) {
common.SysLog("TokenAuth ValidateUserToken database error: " + err.Error())
abortWithOpenAiMessage(c, http.StatusInternalServerError,
common.TranslateMessage(c, i18n.MsgDatabaseError))
} else {
abortWithOpenAiMessage(c, http.StatusUnauthorized,
common.TranslateMessage(c, i18n.MsgTokenInvalid))
}
return
}
@@ -331,12 +366,14 @@ func TokenAuth() func(c *gin.Context) {
userCache, err := model.GetUserCache(token.UserId)
if err != nil {
abortWithOpenAiMessage(c, http.StatusInternalServerError, err.Error())
common.SysLog(fmt.Sprintf("TokenAuth GetUserCache error for user %d: %v", token.UserId, err))
abortWithOpenAiMessage(c, http.StatusInternalServerError,
common.TranslateMessage(c, i18n.MsgDatabaseError))
return
}
userEnabled := userCache.Status == common.UserStatusEnabled
if !userEnabled {
abortWithOpenAiMessage(c, http.StatusForbidden, "用户已被封禁")
abortWithOpenAiMessage(c, http.StatusForbidden, common.TranslateMessage(c, i18n.MsgAuthUserBanned))
return
}
+12 -10
View File
@@ -10,7 +10,8 @@ import (
const (
// SecureVerificationSessionKey 安全验证的 session key(与 controller 保持一致)
SecureVerificationSessionKey = "secure_verified_at"
SecureVerificationSessionKey = "secure_verified_at"
secureVerificationMethodSessionKey = "secure_verified_method"
// SecureVerificationTimeout 验证有效期(秒)
SecureVerificationTimeout = 300 // 5分钟
)
@@ -48,8 +49,7 @@ func SecureVerificationRequired() gin.HandlerFunc {
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
// session 数据格式错误
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
clearSecureVerificationSession(session)
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "验证状态异常,请重新验证",
@@ -63,8 +63,7 @@ func SecureVerificationRequired() gin.HandlerFunc {
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
// 验证已过期,清除 session
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
clearSecureVerificationSession(session)
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "验证已过期,请重新验证",
@@ -74,11 +73,16 @@ func SecureVerificationRequired() gin.HandlerFunc {
return
}
// 验证有效,继续处理请求
c.Next()
}
}
func clearSecureVerificationSession(session sessions.Session) {
session.Delete(SecureVerificationSessionKey)
session.Delete(secureVerificationMethodSessionKey)
_ = session.Save()
}
// OptionalSecureVerification 可选的安全验证中间件
// 如果用户已验证,则在 context 中设置标记,但不阻止请求继续
// 用于某些需要区分是否已验证的场景
@@ -109,8 +113,7 @@ func OptionalSecureVerification() gin.HandlerFunc {
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
clearSecureVerificationSession(session)
c.Set("secure_verified", false)
c.Next()
return
@@ -126,6 +129,5 @@ func OptionalSecureVerification() gin.HandlerFunc {
// 用于用户登出或需要强制重新验证的场景
func ClearSecureVerification(c *gin.Context) {
session := sessions.Default(c)
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
clearSecureVerificationSession(session)
}
+26
View File
@@ -0,0 +1,26 @@
package model
import "errors"
// Common errors
var (
ErrDatabase = errors.New("database error")
)
// User auth errors
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUserEmptyCredentials = errors.New("empty credentials")
)
// Token auth errors
var (
ErrTokenNotProvided = errors.New("token not provided")
ErrTokenInvalid = errors.New("token invalid")
)
// Redemption errors
var ErrRedeemFailed = errors.New("redeem.failed")
// 2FA errors
var ErrTwoFANotEnabled = errors.New("2fa not enabled")
+52
View File
@@ -90,6 +90,58 @@ func RecordLog(userId int, logType int, content string) {
}
}
// RecordLogWithAdminInfo 记录操作日志,并将管理员相关信息存入 Other.admin_info
func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo map[string]interface{}) {
if logType == LogTypeConsume && !common.LogConsumeEnabled {
return
}
username, _ := GetUsernameById(userId, false)
log := &Log{
UserId: userId,
Username: username,
CreatedAt: common.GetTimestamp(),
Type: logType,
Content: content,
}
if len(adminInfo) > 0 {
other := map[string]interface{}{
"admin_info": adminInfo,
}
log.Other = common.MapToJsonStr(other)
}
if err := LOG_DB.Create(log).Error; err != nil {
common.SysLog("failed to record log: " + err.Error())
}
}
func RecordTopupLog(userId int, content string, callerIp string, paymentMethod string, callbackPaymentMethod string) {
username, _ := GetUsernameById(userId, false)
adminInfo := map[string]interface{}{
"server_ip": common.GetIp(),
"node_name": common.NodeName,
"caller_ip": callerIp,
"payment_method": paymentMethod,
"callback_payment_method": callbackPaymentMethod,
"version": common.Version,
}
other := map[string]interface{}{
"admin_info": adminInfo,
}
log := &Log{
UserId: userId,
Username: username,
CreatedAt: common.GetTimestamp(),
Type: LogTypeTopup,
Content: content,
Ip: callerIp,
Other: common.MapToJsonStr(other),
}
err := LOG_DB.Create(log).Error
if err != nil {
common.SysLog("failed to record topup log: " + err.Error())
}
}
func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int,
isStream bool, group string, other map[string]interface{}) {
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
+40 -1
View File
@@ -62,6 +62,7 @@ func InitOptionMap() {
common.OptionMap["SMTPAccount"] = ""
common.OptionMap["SMTPToken"] = ""
common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled)
common.OptionMap["SMTPForceAuthLogin"] = strconv.FormatBool(common.SMTPForceAuthLogin)
common.OptionMap["Notice"] = ""
common.OptionMap["About"] = ""
common.OptionMap["HomePageContent"] = ""
@@ -105,6 +106,18 @@ func InitOptionMap() {
common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64)
common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp)
common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString()
common.OptionMap["WaffoPancakeEnabled"] = strconv.FormatBool(setting.WaffoPancakeEnabled)
common.OptionMap["WaffoPancakeSandbox"] = strconv.FormatBool(setting.WaffoPancakeSandbox)
common.OptionMap["WaffoPancakeMerchantID"] = setting.WaffoPancakeMerchantID
common.OptionMap["WaffoPancakePrivateKey"] = setting.WaffoPancakePrivateKey
common.OptionMap["WaffoPancakeWebhookPublicKey"] = setting.WaffoPancakeWebhookPublicKey
common.OptionMap["WaffoPancakeWebhookTestKey"] = setting.WaffoPancakeWebhookTestKey
common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID
common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID
common.OptionMap["WaffoPancakeReturnURL"] = setting.WaffoPancakeReturnURL
common.OptionMap["WaffoPancakeCurrency"] = setting.WaffoPancakeCurrency
common.OptionMap["WaffoPancakeUnitPrice"] = strconv.FormatFloat(setting.WaffoPancakeUnitPrice, 'f', -1, 64)
common.OptionMap["WaffoPancakeMinTopUp"] = strconv.Itoa(setting.WaffoPancakeMinTopUp)
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
@@ -233,7 +246,7 @@ func updateOptionMap(key string, value string) (err error) {
common.ImageDownloadPermission = intValue
}
}
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" {
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" || key == "SMTPForceAuthLogin" {
boolValue := value == "true"
switch key {
case "PasswordRegisterEnabled":
@@ -308,6 +321,8 @@ func updateOptionMap(key string, value string) (err error) {
setting.StopOnSensitiveEnabled = boolValue
case "SMTPSSLEnabled":
common.SMTPSSLEnabled = boolValue
case "SMTPForceAuthLogin":
common.SMTPForceAuthLogin = boolValue
case "WorkerAllowHttpImageRequestEnabled":
system_setting.WorkerAllowHttpImageRequestEnabled = boolValue
case "DefaultUseAutoGroup":
@@ -404,6 +419,30 @@ func updateOptionMap(key string, value string) (err error) {
setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64)
case "WaffoMinTopUp":
setting.WaffoMinTopUp, _ = strconv.Atoi(value)
case "WaffoPancakeEnabled":
setting.WaffoPancakeEnabled = value == "true"
case "WaffoPancakeSandbox":
setting.WaffoPancakeSandbox = value == "true"
case "WaffoPancakeMerchantID":
setting.WaffoPancakeMerchantID = value
case "WaffoPancakePrivateKey":
setting.WaffoPancakePrivateKey = value
case "WaffoPancakeWebhookPublicKey":
setting.WaffoPancakeWebhookPublicKey = value
case "WaffoPancakeWebhookTestKey":
setting.WaffoPancakeWebhookTestKey = value
case "WaffoPancakeStoreID":
setting.WaffoPancakeStoreID = value
case "WaffoPancakeProductID":
setting.WaffoPancakeProductID = value
case "WaffoPancakeReturnURL":
setting.WaffoPancakeReturnURL = value
case "WaffoPancakeCurrency":
setting.WaffoPancakeCurrency = value
case "WaffoPancakeUnitPrice":
setting.WaffoPancakeUnitPrice, _ = strconv.ParseFloat(value, 64)
case "WaffoPancakeMinTopUp":
setting.WaffoPancakeMinTopUp, _ = strconv.Atoi(value)
case "TopupGroupRatio":
err = common.UpdateTopupGroupRatioByJSONString(value)
case "GitHubClientId":
+172
View File
@@ -0,0 +1,172 @@
package model
import (
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func insertUserForPaymentGuardTest(t *testing.T, id int, quota int) {
t.Helper()
user := &User{
Id: id,
Username: "payment_guard_user",
Status: common.UserStatusEnabled,
Quota: quota,
}
require.NoError(t, DB.Create(user).Error)
}
func insertSubscriptionPlanForPaymentGuardTest(t *testing.T, id int) *SubscriptionPlan {
t.Helper()
plan := &SubscriptionPlan{
Id: id,
Title: "Guard Plan",
PriceAmount: 9.99,
Currency: "USD",
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
Enabled: true,
TotalAmount: 1000,
}
require.NoError(t, DB.Create(plan).Error)
return plan
}
func insertSubscriptionOrderForPaymentGuardTest(t *testing.T, tradeNo string, userID int, planID int, paymentMethod string) {
t.Helper()
order := &SubscriptionOrder{
UserId: userID,
PlanId: planID,
Money: 9.99,
TradeNo: tradeNo,
PaymentMethod: paymentMethod,
Status: common.TopUpStatusPending,
CreateTime: time.Now().Unix(),
}
require.NoError(t, order.Insert())
}
func insertTopUpForPaymentGuardTest(t *testing.T, tradeNo string, userID int, paymentMethod string) {
t.Helper()
topUp := &TopUp{
UserId: userID,
Amount: 2,
Money: 9.99,
TradeNo: tradeNo,
PaymentMethod: paymentMethod,
Status: common.TopUpStatusPending,
CreateTime: time.Now().Unix(),
}
require.NoError(t, topUp.Insert())
}
func getTopUpStatusForPaymentGuardTest(t *testing.T, tradeNo string) string {
t.Helper()
topUp := GetTopUpByTradeNo(tradeNo)
require.NotNil(t, topUp)
return topUp.Status
}
func countUserSubscriptionsForPaymentGuardTest(t *testing.T, userID int) int64 {
t.Helper()
var count int64
require.NoError(t, DB.Model(&UserSubscription{}).Where("user_id = ?", userID).Count(&count).Error)
return count
}
func getUserQuotaForPaymentGuardTest(t *testing.T, userID int) int {
t.Helper()
var user User
require.NoError(t, DB.Select("quota").Where("id = ?", userID).First(&user).Error)
return user.Quota
}
func TestRechargeWaffoPancake_RejectsMismatchedPaymentMethod(t *testing.T) {
truncateTables(t)
insertUserForPaymentGuardTest(t, 101, 0)
insertTopUpForPaymentGuardTest(t, "waffo-pancake-guard", 101, PaymentMethodStripe)
err := RechargeWaffoPancake("waffo-pancake-guard")
require.Error(t, err)
topUp := GetTopUpByTradeNo("waffo-pancake-guard")
require.NotNil(t, topUp)
assert.Equal(t, common.TopUpStatusPending, topUp.Status)
assert.Equal(t, 0, getUserQuotaForPaymentGuardTest(t, 101))
}
func TestUpdatePendingTopUpStatus_RejectsMismatchedPaymentMethod(t *testing.T) {
testCases := []struct {
name string
tradeNo string
storedPaymentMethod string
expectedPaymentMethod string
targetStatus string
}{
{
name: "stripe expire",
tradeNo: "stripe-expire-guard",
storedPaymentMethod: PaymentMethodCreem,
expectedPaymentMethod: PaymentMethodStripe,
targetStatus: common.TopUpStatusExpired,
},
{
name: "waffo failed",
tradeNo: "waffo-failed-guard",
storedPaymentMethod: PaymentMethodStripe,
expectedPaymentMethod: PaymentMethodWaffo,
targetStatus: common.TopUpStatusFailed,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
truncateTables(t)
insertUserForPaymentGuardTest(t, 150, 0)
insertTopUpForPaymentGuardTest(t, tc.tradeNo, 150, tc.storedPaymentMethod)
err := UpdatePendingTopUpStatus(tc.tradeNo, tc.expectedPaymentMethod, tc.targetStatus)
require.ErrorIs(t, err, ErrPaymentMethodMismatch)
assert.Equal(t, common.TopUpStatusPending, getTopUpStatusForPaymentGuardTest(t, tc.tradeNo))
})
}
}
func TestCompleteSubscriptionOrder_RejectsMismatchedPaymentMethod(t *testing.T) {
truncateTables(t)
insertUserForPaymentGuardTest(t, 202, 0)
plan := insertSubscriptionPlanForPaymentGuardTest(t, 301)
insertSubscriptionOrderForPaymentGuardTest(t, "sub-guard-order", 202, plan.Id, PaymentMethodStripe)
err := CompleteSubscriptionOrder("sub-guard-order", `{"provider":"epay"}`, "alipay")
require.ErrorIs(t, err, ErrPaymentMethodMismatch)
order := GetSubscriptionOrderByTradeNo("sub-guard-order")
require.NotNil(t, order)
assert.Equal(t, common.TopUpStatusPending, order.Status)
assert.Zero(t, countUserSubscriptionsForPaymentGuardTest(t, 202))
topUp := GetTopUpByTradeNo("sub-guard-order")
assert.Nil(t, topUp)
}
func TestExpireSubscriptionOrder_RejectsMismatchedPaymentMethod(t *testing.T) {
truncateTables(t)
insertUserForPaymentGuardTest(t, 303, 0)
plan := insertSubscriptionPlanForPaymentGuardTest(t, 401)
insertSubscriptionOrderForPaymentGuardTest(t, "sub-expire-guard", 303, plan.Id, PaymentMethodStripe)
err := ExpireSubscriptionOrder("sub-expire-guard", PaymentMethodCreem)
require.ErrorIs(t, err, ErrPaymentMethodMismatch)
order := GetSubscriptionOrderByTradeNo("sub-expire-guard")
require.NotNil(t, order)
assert.Equal(t, common.TopUpStatusPending, order.Status)
}
-3
View File
@@ -11,9 +11,6 @@ import (
"gorm.io/gorm"
)
// ErrRedeemFailed is returned when redemption fails due to database error
var ErrRedeemFailed = errors.New("redeem.failed")
type Redemption struct {
Id int `json:"id"`
UserId int `json:"user_id"`
+10 -2
View File
@@ -505,7 +505,7 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
}
// Complete a subscription order (idempotent). Creates a UserSubscription snapshot from the plan.
func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error {
func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedPaymentMethod string) error {
if tradeNo == "" {
return errors.New("tradeNo is empty")
}
@@ -523,6 +523,9 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error {
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil {
return ErrSubscriptionOrderNotFound
}
if expectedPaymentMethod != "" && order.PaymentMethod != expectedPaymentMethod {
return ErrPaymentMethodMismatch
}
if order.Status == common.TopUpStatusSuccess {
return nil
}
@@ -596,6 +599,8 @@ func upsertSubscriptionTopUpTx(tx *gorm.DB, order *SubscriptionOrder) error {
topup.Money = order.Money
if topup.PaymentMethod == "" {
topup.PaymentMethod = order.PaymentMethod
} else if topup.PaymentMethod != order.PaymentMethod {
return ErrPaymentMethodMismatch
}
if topup.CreateTime == 0 {
topup.CreateTime = order.CreateTime
@@ -605,7 +610,7 @@ func upsertSubscriptionTopUpTx(tx *gorm.DB, order *SubscriptionOrder) error {
return tx.Save(&topup).Error
}
func ExpireSubscriptionOrder(tradeNo string) error {
func ExpireSubscriptionOrder(tradeNo string, expectedPaymentMethod string) error {
if tradeNo == "" {
return errors.New("tradeNo is empty")
}
@@ -618,6 +623,9 @@ func ExpireSubscriptionOrder(tradeNo string) error {
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil {
return ErrSubscriptionOrderNotFound
}
if expectedPaymentMethod != "" && order.PaymentMethod != expectedPaymentMethod {
return ErrPaymentMethodMismatch
}
if order.Status != common.TopUpStatusPending {
return nil
}
+15 -1
View File
@@ -33,7 +33,17 @@ func TestMain(m *testing.M) {
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&Task{}, &User{}, &Token{}, &Log{}, &Channel{}); err != nil {
if err := db.AutoMigrate(
&Task{},
&User{},
&Token{},
&Log{},
&Channel{},
&TopUp{},
&SubscriptionPlan{},
&SubscriptionOrder{},
&UserSubscription{},
); err != nil {
panic("failed to migrate: " + err.Error())
}
@@ -48,6 +58,10 @@ func truncateTables(t *testing.T) {
DB.Exec("DELETE FROM tokens")
DB.Exec("DELETE FROM logs")
DB.Exec("DELETE FROM channels")
DB.Exec("DELETE FROM top_ups")
DB.Exec("DELETE FROM subscription_orders")
DB.Exec("DELETE FROM subscription_plans")
DB.Exec("DELETE FROM user_subscriptions")
})
}
+46 -18
View File
@@ -187,19 +187,14 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi
func ValidateUserToken(key string) (token *Token, err error) {
if key == "" {
return nil, errors.New("未提供令牌")
return nil, ErrTokenNotProvided
}
token, err = GetTokenByKey(key, false)
if err == nil {
if token.Status == common.TokenStatusExhausted {
keyPrefix := key[:3]
keySuffix := key[len(key)-3:]
return token, errors.New("该令牌额度已用尽 TokenStatusExhausted[sk-" + keyPrefix + "***" + keySuffix + "]")
} else if token.Status == common.TokenStatusExpired {
return token, errors.New("该令牌已过期")
}
if token.Status != common.TokenStatusEnabled {
return token, errors.New("该令牌状态不可用")
if token.Status == common.TokenStatusExhausted ||
token.Status == common.TokenStatusExpired ||
token.Status != common.TokenStatusEnabled {
return token, ErrTokenInvalid
}
if token.ExpiredTime != -1 && token.ExpiredTime < common.GetTimestamp() {
if !common.RedisEnabled {
@@ -209,29 +204,25 @@ func ValidateUserToken(key string) (token *Token, err error) {
common.SysLog("failed to update token status" + err.Error())
}
}
return token, errors.New("该令牌已过期")
return token, ErrTokenInvalid
}
if !token.UnlimitedQuota && token.RemainQuota <= 0 {
if !common.RedisEnabled {
// in this case, we can make sure the token is exhausted
token.Status = common.TokenStatusExhausted
err := token.SelectUpdate()
if err != nil {
common.SysLog("failed to update token status" + err.Error())
}
}
keyPrefix := key[:3]
keySuffix := key[len(key)-3:]
return token, fmt.Errorf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota)
return token, ErrTokenInvalid
}
return token, nil
}
common.SysLog("ValidateUserToken: failed to get token: " + err.Error())
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("无效的令牌")
} else {
return nil, errors.New("无效的令牌,数据库查询出错,请联系管理员")
return nil, ErrTokenInvalid
}
return nil, fmt.Errorf("%w: %v", ErrDatabase, err)
}
func GetTokenByIds(id int, userId int) (*Token, error) {
@@ -481,3 +472,40 @@ func BatchDeleteTokens(ids []int, userId int) (int, error) {
return len(tokens), nil
}
func GetTokenKeysByIds(ids []int, userId int) ([]Token, error) {
var tokens []Token
err := DB.Select("id", commonKeyCol).
Where("user_id = ? AND id IN (?)", userId, ids).
Find(&tokens).Error
return tokens, err
}
// InvalidateUserTokensCache 清理指定用户所有令牌在 Redis 中的缓存,
// 配合 InvalidateUserCache 使用,可在用户被禁用/删除时立即阻断其令牌的请求。
// 下一次请求将从数据库重新加载令牌及用户状态,从而立即识别出被禁用的用户。
func InvalidateUserTokensCache(userId int) error {
if !common.RedisEnabled {
return nil
}
if userId <= 0 {
return errors.New("userId 无效")
}
var tokens []Token
if err := DB.Unscoped().
Select("id", commonKeyCol).
Where("user_id = ?", userId).
Find(&tokens).Error; err != nil {
return err
}
var firstErr error
for _, t := range tokens {
if t.Key == "" {
continue
}
if err := cacheDeleteToken(t.Key); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
+174 -33
View File
@@ -12,17 +12,30 @@ import (
)
type TopUp struct {
Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"`
Amount int64 `json:"amount"`
Money float64 `json:"money"`
TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"`
CreateTime int64 `json:"create_time"`
CompleteTime int64 `json:"complete_time"`
Status string `json:"status"`
Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"`
Amount int64 `json:"amount"`
Money float64 `json:"money"`
TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"`
CreateTime int64 `json:"create_time"`
CompleteTime int64 `json:"complete_time"`
Status string `json:"status"`
}
const (
PaymentMethodStripe = "stripe"
PaymentMethodCreem = "creem"
PaymentMethodWaffo = "waffo"
PaymentMethodWaffoPancake = "waffo_pancake"
)
var (
ErrPaymentMethodMismatch = errors.New("payment method mismatch")
ErrTopUpNotFound = errors.New("topup not found")
ErrTopUpStatusInvalid = errors.New("topup status invalid")
)
func (topUp *TopUp) Insert() error {
var err error
err = DB.Create(topUp).Error
@@ -55,7 +68,34 @@ func GetTopUpByTradeNo(tradeNo string) *TopUp {
return topUp
}
func Recharge(referenceId string, customerId string) (err error) {
func UpdatePendingTopUpStatus(tradeNo string, expectedPaymentMethod string, targetStatus string) error {
if tradeNo == "" {
return errors.New("未提供支付单号")
}
refCol := "`trade_no`"
if common.UsingPostgreSQL {
refCol = `"trade_no"`
}
return DB.Transaction(func(tx *gorm.DB) error {
topUp := &TopUp{}
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
return ErrTopUpNotFound
}
if expectedPaymentMethod != "" && topUp.PaymentMethod != expectedPaymentMethod {
return ErrPaymentMethodMismatch
}
if topUp.Status != common.TopUpStatusPending {
return ErrTopUpStatusInvalid
}
topUp.Status = targetStatus
return tx.Save(topUp).Error
})
}
func Recharge(referenceId string, customerId string, callerIp string) (err error) {
if referenceId == "" {
return errors.New("未提供支付单号")
}
@@ -74,6 +114,10 @@ func Recharge(referenceId string, customerId string) (err error) {
return errors.New("充值订单不存在")
}
if topUp.PaymentMethod != PaymentMethodStripe {
return ErrPaymentMethodMismatch
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
@@ -99,11 +143,19 @@ func Recharge(referenceId string, customerId string) (err error) {
return errors.New("充值失败,请稍后重试")
}
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount))
RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount), callerIp, topUp.PaymentMethod, PaymentMethodStripe)
return nil
}
// topUpQueryWindowSeconds 限制充值记录查询的时间窗口(秒)。
const topUpQueryWindowSeconds int64 = 30 * 24 * 60 * 60
// topUpQueryCutoff 返回允许查询的最早 create_time(秒级 Unix 时间戳)。
func topUpQueryCutoff() int64 {
return common.GetTimestamp() - topUpQueryWindowSeconds
}
func GetUserTopUps(userId int, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
// Start transaction
tx := DB.Begin()
@@ -116,15 +168,17 @@ func GetUserTopUps(userId int, pageInfo *common.PageInfo) (topups []*TopUp, tota
}
}()
cutoff := topUpQueryCutoff()
// Get total count within transaction
err = tx.Model(&TopUp{}).Where("user_id = ?", userId).Count(&total).Error
err = tx.Model(&TopUp{}).Where("user_id = ? AND create_time >= ?", userId, cutoff).Count(&total).Error
if err != nil {
tx.Rollback()
return nil, 0, err
}
// Get paginated topups within same transaction
err = tx.Where("user_id = ?", userId).Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error
err = tx.Where("user_id = ? AND create_time >= ?", userId, cutoff).Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error
if err != nil {
tx.Rollback()
return nil, 0, err
@@ -138,7 +192,7 @@ func GetUserTopUps(userId int, pageInfo *common.PageInfo) (topups []*TopUp, tota
return topups, total, nil
}
// GetAllTopUps 获取全平台的充值记录(管理员使用)
// GetAllTopUps 获取全平台的充值记录(管理员使用,不限制时间窗口
func GetAllTopUps(pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
tx := DB.Begin()
if tx.Error != nil {
@@ -167,6 +221,10 @@ func GetAllTopUps(pageInfo *common.PageInfo) (topups []*TopUp, total int64, err
return topups, total, nil
}
// searchTopUpCountHardLimit 搜索充值记录时 COUNT 的安全上限,
// 防止对超大表执行无界 COUNT 触发 DoS。
const searchTopUpCountHardLimit = 10000
// SearchUserTopUps 按订单号搜索某用户的充值记录
func SearchUserTopUps(userId int, keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
tx := DB.Begin()
@@ -179,20 +237,26 @@ func SearchUserTopUps(userId int, keyword string, pageInfo *common.PageInfo) (to
}
}()
query := tx.Model(&TopUp{}).Where("user_id = ?", userId)
query := tx.Model(&TopUp{}).Where("user_id = ? AND create_time >= ?", userId, topUpQueryCutoff())
if keyword != "" {
like := "%%" + keyword + "%%"
query = query.Where("trade_no LIKE ?", like)
pattern, perr := sanitizeLikePattern(keyword)
if perr != nil {
tx.Rollback()
return nil, 0, perr
}
query = query.Where("trade_no LIKE ? ESCAPE '!'", pattern)
}
if err = query.Count(&total).Error; err != nil {
if err = query.Limit(searchTopUpCountHardLimit).Count(&total).Error; err != nil {
tx.Rollback()
return nil, 0, err
common.SysError("failed to count search topups: " + err.Error())
return nil, 0, errors.New("搜索充值记录失败")
}
if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
tx.Rollback()
return nil, 0, err
common.SysError("failed to search topups: " + err.Error())
return nil, 0, errors.New("搜索充值记录失败")
}
if err = tx.Commit().Error; err != nil {
@@ -201,7 +265,7 @@ func SearchUserTopUps(userId int, keyword string, pageInfo *common.PageInfo) (to
return topups, total, nil
}
// SearchAllTopUps 按订单号搜索全平台充值记录(管理员使用)
// SearchAllTopUps 按订单号搜索全平台充值记录(管理员使用,不限制时间窗口
func SearchAllTopUps(keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
tx := DB.Begin()
if tx.Error != nil {
@@ -215,18 +279,24 @@ func SearchAllTopUps(keyword string, pageInfo *common.PageInfo) (topups []*TopUp
query := tx.Model(&TopUp{})
if keyword != "" {
like := "%%" + keyword + "%%"
query = query.Where("trade_no LIKE ?", like)
pattern, perr := sanitizeLikePattern(keyword)
if perr != nil {
tx.Rollback()
return nil, 0, perr
}
query = query.Where("trade_no LIKE ? ESCAPE '!'", pattern)
}
if err = query.Count(&total).Error; err != nil {
if err = query.Limit(searchTopUpCountHardLimit).Count(&total).Error; err != nil {
tx.Rollback()
return nil, 0, err
common.SysError("failed to count search topups: " + err.Error())
return nil, 0, errors.New("搜索充值记录失败")
}
if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
tx.Rollback()
return nil, 0, err
common.SysError("failed to search topups: " + err.Error())
return nil, 0, errors.New("搜索充值记录失败")
}
if err = tx.Commit().Error; err != nil {
@@ -236,7 +306,7 @@ func SearchAllTopUps(keyword string, pageInfo *common.PageInfo) (topups []*TopUp
}
// ManualCompleteTopUp 管理员手动完成订单并给用户充值
func ManualCompleteTopUp(tradeNo string) error {
func ManualCompleteTopUp(tradeNo string, callerIp string) error {
if tradeNo == "" {
return errors.New("未提供订单号")
}
@@ -249,6 +319,7 @@ func ManualCompleteTopUp(tradeNo string) error {
var userId int
var quotaToAdd int
var payMoney float64
var paymentMethod string
err := DB.Transaction(func(tx *gorm.DB) error {
topUp := &TopUp{}
@@ -269,7 +340,7 @@ func ManualCompleteTopUp(tradeNo string) error {
// 计算应充值额度:
// - Stripe 订单:Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit
// - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit
if topUp.PaymentMethod == "stripe" {
if topUp.PaymentMethod == PaymentMethodStripe {
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart())
} else {
@@ -295,6 +366,7 @@ func ManualCompleteTopUp(tradeNo string) error {
userId = topUp.UserId
payMoney = topUp.Money
paymentMethod = topUp.PaymentMethod
return nil
})
@@ -303,10 +375,10 @@ func ManualCompleteTopUp(tradeNo string) error {
}
// 事务外记录日志,避免阻塞
RecordLog(userId, LogTypeTopup, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney))
RecordTopupLog(userId, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney), callerIp, paymentMethod, "admin")
return nil
}
func RechargeCreem(referenceId string, customerEmail string, customerName string) (err error) {
func RechargeCreem(referenceId string, customerEmail string, customerName string, callerIp string) (err error) {
if referenceId == "" {
return errors.New("未提供支付单号")
}
@@ -325,6 +397,10 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
return errors.New("充值订单不存在")
}
if topUp.PaymentMethod != PaymentMethodCreem {
return ErrPaymentMethodMismatch
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
@@ -372,12 +448,12 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
return errors.New("充值失败,请稍后重试")
}
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money))
RecordTopupLog(topUp.UserId, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodCreem)
return nil
}
func RechargeWaffo(tradeNo string) (err error) {
func RechargeWaffo(tradeNo string, callerIp string) (err error) {
if tradeNo == "" {
return errors.New("未提供支付单号")
}
@@ -396,6 +472,10 @@ func RechargeWaffo(tradeNo string) (err error) {
return errors.New("充值订单不存在")
}
if topUp.PaymentMethod != PaymentMethodWaffo {
return ErrPaymentMethodMismatch
}
if topUp.Status == common.TopUpStatusSuccess {
return nil // 幂等:已成功直接返回
}
@@ -430,7 +510,68 @@ func RechargeWaffo(tradeNo string) (err error) {
}
if quotaToAdd > 0 {
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money))
RecordTopupLog(topUp.UserId, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodWaffo)
}
return nil
}
func RechargeWaffoPancake(tradeNo string) (err error) {
if tradeNo == "" {
return errors.New("未提供支付单号")
}
var quotaToAdd int
topUp := &TopUp{}
refCol := "`trade_no`"
if common.UsingPostgreSQL {
refCol = `"trade_no"`
}
err = DB.Transaction(func(tx *gorm.DB) error {
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error
if err != nil {
return errors.New("充值订单不存在")
}
if topUp.PaymentMethod != PaymentMethodWaffoPancake {
return ErrPaymentMethodMismatch
}
if topUp.Status == common.TopUpStatusSuccess {
return nil
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
quotaToAdd = int(decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).IntPart())
if quotaToAdd <= 0 {
return errors.New("无效的充值额度")
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
if err := tx.Save(topUp).Error; err != nil {
return err
}
if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
return err
}
return nil
})
if err != nil {
common.SysError("waffo pancake topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
if quotaToAdd > 0 {
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo Pancake充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money))
}
return nil
-2
View File
@@ -10,8 +10,6 @@ import (
"gorm.io/gorm"
)
var ErrTwoFANotEnabled = errors.New("用户未启用2FA")
// TwoFA 用户2FA设置表
type TwoFA struct {
Id int `json:"id" gorm:"primaryKey"`
+10
View File
@@ -115,6 +115,16 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData
return quotaDatas, err
}
func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
var quotaDatas []*QuotaData
err = DB.Table("quota_data").
Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
Where("created_at >= ? and created_at <= ?", startTime, endTime).
Group("username, created_at").
Find(&quotaDatas).Error
return quotaDatas, err
}
func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) {
if username != "" {
return GetQuotaDataByUsername(username, startTime, endTime)
+23 -14
View File
@@ -523,7 +523,6 @@ func (user *User) Edit(updatePassword bool) error {
"username": newUser.Username,
"display_name": newUser.DisplayName,
"group": newUser.Group,
"quota": newUser.Quota,
"remark": newUser.Remark,
}
if updatePassword {
@@ -598,13 +597,19 @@ func (user *User) ValidateAndFill() (err error) {
password := user.Password
username := strings.TrimSpace(user.Username)
if username == "" || password == "" {
return errors.New("用户名或密码为空")
return ErrUserEmptyCredentials
}
// find by username or email
err = DB.Where("username = ? OR email = ?", username, username).First(user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrInvalidCredentials
}
return fmt.Errorf("%w: %v", ErrDatabase, err)
}
// find buy username or email
DB.Where("username = ? OR email = ?", username, username).First(user)
okay := common.ValidatePasswordAndHash(password, user.Password)
if !okay || user.Status != common.UserStatusEnabled {
return errors.New("用户名或密码错误,或用户已被封禁")
return ErrInvalidCredentials
}
return nil
}
@@ -755,16 +760,20 @@ func IsAdmin(userId int) bool {
// return user.Status == common.UserStatusEnabled, nil
//}
func ValidateAccessToken(token string) (user *User) {
func ValidateAccessToken(token string) (*User, error) {
if token == "" {
return nil
return nil, nil
}
token = strings.Replace(token, "Bearer ", "", 1)
user = &User{}
if DB.Where("access_token = ?", token).First(user).RowsAffected == 1 {
return user
user := &User{}
err := DB.Where("access_token = ?", token).First(user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, fmt.Errorf("%w: %v", ErrDatabase, err)
}
return nil
return user, nil
}
// GetUserQuota gets quota from Redis first, falls back to DB if needed
@@ -896,7 +905,7 @@ func increaseUserQuota(id int, quota int) (err error) {
return err
}
func DecreaseUserQuota(id int, quota int) (err error) {
func DecreaseUserQuota(id int, quota int, db bool) (err error) {
if quota < 0 {
return errors.New("quota 不能为负数!")
}
@@ -906,7 +915,7 @@ func DecreaseUserQuota(id int, quota int) (err error) {
common.SysLog("failed to decrease user quota: " + err.Error())
}
})
if common.BatchUpdateEnabled {
if !db && common.BatchUpdateEnabled {
addNewRecord(BatchUpdateTypeUserQuota, id, -quota)
return nil
}
@@ -928,7 +937,7 @@ func DeltaUpdateUserQuota(id int, delta int) (err error) {
if delta > 0 {
return IncreaseUserQuota(id, delta, false)
} else {
return DecreaseUserQuota(id, -delta)
return DecreaseUserQuota(id, -delta, false)
}
}
+6
View File
@@ -57,6 +57,12 @@ func invalidateUserCache(userId int) error {
return common.RedisDelKey(getUserCacheKey(userId))
}
// InvalidateUserCache is the exported version of invalidateUserCache.
// 供 controller 等上层包在用户状态变更(如禁用、删除、角色变更)后主动清理缓存。
func InvalidateUserCache(userId int) error {
return invalidateUserCache(userId)
}
// updateUserCache updates all user cache fields using hash
func updateUserCache(user User) error {
if !common.RedisEnabled {
+6
View File
@@ -18,6 +18,7 @@ var awsModelIDMap = map[string]string{
"claude-haiku-4-5-20251001": "anthropic.claude-haiku-4-5-20251001-v1:0",
"claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0",
"claude-opus-4-6": "anthropic.claude-opus-4-6-v1",
"claude-opus-4-7": "anthropic.claude-opus-4-7",
// Nova models
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
@@ -91,6 +92,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
"ap": true,
"eu": true,
},
"anthropic.claude-opus-4-7": {
"us": true,
"ap": true,
"eu": true,
},
"anthropic.claude-haiku-4-5-20251001-v1:0": {
"us": true,
"ap": true,
+7
View File
@@ -26,6 +26,13 @@ var ModelList = []string{
"claude-opus-4-6-medium",
"claude-opus-4-6-low",
"claude-sonnet-4-6",
"claude-opus-4-7",
"claude-opus-4-7-max",
"claude-opus-4-7-xhigh",
"claude-opus-4-7-high",
"claude-opus-4-7-medium",
"claude-opus-4-7-low",
"claude-opus-4-7-thinking",
}
var ChannelName = "claude"
@@ -85,7 +85,7 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
require.EqualValues(t, 50, usage.CacheCreationInputTokens)
require.EqualValues(t, 53, usage.OutputTokens)
require.NotNil(t, usage.CacheCreation)
require.EqualValues(t, 10, usage.CacheCreation.Ephemeral5mInputTokens)
require.EqualValues(t, 30, usage.CacheCreation.Ephemeral5mInputTokens)
require.EqualValues(t, 20, usage.CacheCreation.Ephemeral1hInputTokens)
})
@@ -108,4 +108,22 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
require.EqualValues(t, 7, usage.CacheReadInputTokens)
require.EqualValues(t, 6, usage.CacheCreationInputTokens)
})
t.Run("default aggregate cache creation to 5m when split missing", func(t *testing.T) {
claudeResponse := &dto.ClaudeResponse{Usage: &dto.ClaudeUsage{
OutputTokens: 53,
CacheCreationInputTokens: 50,
}}
claudeInfo := &ClaudeResponseInfo{Usage: &dto.Usage{
PromptTokensDetails: dto.InputTokenDetails{
CachedCreationTokens: 50,
},
}}
usage := buildMessageDeltaPatchUsage(claudeResponse, claudeInfo)
require.NotNil(t, usage)
require.NotNil(t, usage.CacheCreation)
require.EqualValues(t, 50, usage.CacheCreation.Ephemeral5mInputTokens)
require.EqualValues(t, 0, usage.CacheCreation.Ephemeral1hInputTokens)
})
}
+104 -116
View File
@@ -1,12 +1,10 @@
package claude
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"github.com/QuantumNous/new-api/common"
@@ -46,61 +44,6 @@ func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) {
}
}
func createClaudeFileSource(file *dto.MessageFile) *types.FileSource {
if file == nil || file.FileData == "" {
return nil
}
if strings.HasPrefix(file.FileData, "http://") || strings.HasPrefix(file.FileData, "https://") {
return types.NewURLFileSource(file.FileData)
}
mimeType := ""
if ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.FileName)), "."); ext != "" {
if detected := service.GetMimeTypeByExtension(ext); detected != "application/octet-stream" {
mimeType = detected
}
}
return types.NewBase64FileSource(file.FileData, mimeType)
}
func buildClaudeFileMessage(c *gin.Context, file *dto.MessageFile) (*dto.ClaudeMediaMessage, error) {
source := createClaudeFileSource(file)
if source == nil {
return nil, nil
}
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting document for Claude")
if err != nil {
return nil, fmt.Errorf("get file data failed: %w", err)
}
switch strings.ToLower(mimeType) {
case "application/pdf":
return &dto.ClaudeMediaMessage{
Type: "document",
Source: &dto.ClaudeMessageSource{
Type: "base64",
MediaType: mimeType,
Data: base64Data,
},
}, nil
case "text/plain":
decodedData, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return nil, fmt.Errorf("decode text file data failed: %w", err)
}
return &dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer(string(decodedData)),
}, nil
default:
msg := fmt.Sprintf("claude: skip unsupported file content, filename=%q, mime=%q", file.FileName, mimeType)
if c != nil {
logger.LogInfo(c, msg)
} else {
common.SysLog(msg)
}
return nil, nil
}
}
func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
claudeTools := make([]any, 0, len(textRequest.Tools))
@@ -142,7 +85,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
// 解析 UserLocation JSON
var userLocationMap map[string]interface{}
if err := json.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
if err := common.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
// 检查是否有 approximate 字段
if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok {
if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
@@ -211,33 +154,52 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
}
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
strings.HasPrefix(textRequest.Model, "claude-opus-4-6") {
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") || strings.HasPrefix(textRequest.Model, "claude-opus-4-7")) {
claudeRequest.Model = baseModel
claudeRequest.Thinking = &dto.Thinking{
Type: "adaptive",
}
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
claudeRequest.TopP = common.GetPointer[float64](0)
claudeRequest.Temperature = common.GetPointer[float64](1.0)
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
// and defaults display to "omitted"; restore the 4.6 visible summary.
claudeRequest.Thinking.Display = "summarized"
claudeRequest.Temperature = nil
claudeRequest.TopP = nil
claudeRequest.TopK = nil
} else {
claudeRequest.TopP = nil
claudeRequest.Temperature = common.GetPointer[float64](1.0)
}
} else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
strings.HasSuffix(textRequest.Model, "-thinking") {
// 因为BudgetTokens 必须大于1024
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens < 1280 {
claudeRequest.MaxTokens = common.GetPointer[uint](1280)
}
trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") {
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
claudeRequest.Temperature = nil
claudeRequest.TopP = nil
claudeRequest.TopK = nil
} else {
// 因为BudgetTokens 必须大于1024
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens < 1280 {
claudeRequest.MaxTokens = common.GetPointer[uint](1280)
}
// BudgetTokens 为 max_tokens 的 80%
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: common.GetPointer[int](int(float64(*claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
// BudgetTokens 为 max_tokens 的 80%
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: common.GetPointer[int](int(float64(*claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
}
// TODO: 临时处理
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
claudeRequest.TopP = nil
claudeRequest.Temperature = common.GetPointer[float64](1.0)
}
// TODO: 临时处理
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
claudeRequest.TopP = common.GetPointer[float64](0)
claudeRequest.Temperature = common.GetPointer[float64](1.0)
if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
claudeRequest.Model = trimmedModel
}
}
@@ -315,7 +277,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
formatMessages = formatMessages[:len(formatMessages)-1]
}
}
if fmtMessage.Content == nil {
if fmtMessage.Content == nil || (fmtMessage.IsStringContent() && fmtMessage.StringContent() == "") {
fmtMessage.SetStringContent("...")
}
formatMessages = append(formatMessages, fmtMessage)
@@ -331,14 +293,16 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
if message.Role == "system" {
// 根据Claude API规范,system字段使用数组格式更有通用性
if message.IsStringContent() {
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](message.StringContent()),
})
if text := message.StringContent(); text != "" {
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](text),
})
}
} else {
// 支持复合内容的system消息(虽然不常见,但需要考虑完整性)
for _, ctx := range message.ParseContent() {
if ctx.Type == "text" {
if ctx.Type == "text" && ctx.Text != "" {
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](ctx.Text),
@@ -396,54 +360,49 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
}
}
} else if message.IsStringContent() && message.ToolCalls == nil {
claudeMessage.Content = message.StringContent()
text := message.StringContent()
if text == "" {
text = "..."
}
claudeMessage.Content = text
} else {
claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
for _, mediaMessage := range message.ParseContent() {
switch mediaMessage.Type {
case "text":
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](mediaMessage.Text),
})
case dto.ContentTypeImageURL:
claudeMediaMessage := dto.ClaudeMediaMessage{
Type: "image",
Source: &dto.ClaudeMessageSource{
Type: "base64",
},
if mediaMessage.Text != "" {
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](mediaMessage.Text),
})
}
imageUrl := mediaMessage.GetImageMedia()
if imageUrl == nil {
default:
source := mediaMessage.ToFileSource()
if source == nil {
continue
}
// 使用统一的文件服务获取图片数据
var source *types.FileSource
if strings.HasPrefix(imageUrl.Url, "http") {
source = types.NewURLFileSource(imageUrl.Url)
} else {
source = types.NewBase64FileSource(imageUrl.Url, "")
}
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())
}
claudeMediaMessage := dto.ClaudeMediaMessage{
Source: &dto.ClaudeMessageSource{
Type: "base64",
},
}
if strings.HasPrefix(mimeType, "application/pdf") {
claudeMediaMessage.Type = "document"
} else {
claudeMediaMessage.Type = "image"
}
claudeMediaMessage.Source.MediaType = mimeType
claudeMediaMessage.Source.Data = base64Data
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
// FIXME
//case dto.ContentTypeFile:
// claudeFileMessage, err := buildClaudeFileMessage(c, mediaMessage.GetFile())
// if err != nil {
// return nil, err
// }
// if claudeFileMessage != nil {
// claudeMediaMessages = append(claudeMediaMessages, *claudeFileMessage)
// }
default:
continue
}
}
if message.ToolCalls != nil {
for _, toolCall := range message.ParseToolCalls() {
inputObj := make(map[string]any)
@@ -648,6 +607,11 @@ func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
return dto.Usage{}
}
clone := *usage
clone.ClaudeCacheCreation5mTokens, clone.ClaudeCacheCreation1hTokens = service.NormalizeCacheCreationSplit(
usage.PromptTokensDetails.CachedCreationTokens,
usage.ClaudeCacheCreation5mTokens,
usage.ClaudeCacheCreation1hTokens,
)
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
clone.PromptTokens = totalInputTokens
@@ -677,11 +641,26 @@ func buildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo
if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 {
usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens
}
if usage.CacheCreation == nil && (claudeInfo.Usage.ClaudeCacheCreation5mTokens > 0 || claudeInfo.Usage.ClaudeCacheCreation1hTokens > 0) {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: claudeInfo.Usage.ClaudeCacheCreation5mTokens,
Ephemeral1hInputTokens: claudeInfo.Usage.ClaudeCacheCreation1hTokens,
}
cacheCreation5m := 0
cacheCreation1h := 0
if usage.CacheCreation != nil {
cacheCreation5m = usage.CacheCreation.Ephemeral5mInputTokens
cacheCreation1h = usage.CacheCreation.Ephemeral1hInputTokens
} else {
cacheCreation5m = claudeInfo.Usage.ClaudeCacheCreation5mTokens
cacheCreation1h = claudeInfo.Usage.ClaudeCacheCreation1hTokens
}
cacheCreation5m, cacheCreation1h = service.NormalizeCacheCreationSplit(
usage.CacheCreationInputTokens,
cacheCreation5m,
cacheCreation1h,
)
if usage.CacheCreation == nil && (cacheCreation5m > 0 || cacheCreation1h > 0) {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{}
}
if usage.CacheCreation != nil {
usage.CacheCreation.Ephemeral5mInputTokens = cacheCreation5m
usage.CacheCreation.Ephemeral1hInputTokens = cacheCreation1h
}
return usage
}
@@ -857,7 +836,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
if common.DebugEnabled {
common.SysLog("claude response usage is not complete, maybe upstream error")
}
claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
// 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段
fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
if claudeInfo.Usage.CompletionTokens == 0 ||
(!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) {
claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens
}
if claudeInfo.Usage.PromptTokens == 0 {
claudeInfo.Usage.PromptTokens = fallback.PromptTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
}
if claudeInfo.Usage != nil {
claudeInfo.Usage.UsageSemantic = "anthropic"
+17
View File
@@ -258,6 +258,23 @@ func TestBuildOpenAIStyleUsageFromClaudeUsagePreservesCacheCreationRemainder(t *
}
}
func TestBuildOpenAIStyleUsageFromClaudeUsageDefaultsAggregateCacheCreationTo5m(t *testing.T) {
usage := &dto.Usage{
PromptTokens: 100,
CompletionTokens: 20,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 30,
CachedCreationTokens: 50,
},
UsageSemantic: "anthropic",
}
openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(usage)
require.Equal(t, 50, openAIUsage.ClaudeCacheCreation5mTokens)
require.Equal(t, 0, openAIUsage.ClaudeCacheCreation1hTokens)
}
func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-3-5-sonnet",
+4 -38
View File
@@ -585,14 +585,10 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
Text: part.Text,
})
}
} else if part.Type == dto.ContentTypeImageURL {
// 使用统一的文件服务获取图片数据
var source *types.FileSource
imageUrl := part.GetImageMedia().Url
if strings.HasPrefix(imageUrl, "http") {
source = types.NewURLFileSource(imageUrl)
} else {
source = types.NewBase64FileSource(imageUrl, "")
} else {
source := part.ToFileSource()
if source == nil {
continue
}
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Gemini")
if err != nil {
@@ -604,36 +600,6 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), getSupportedMimeTypesList())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
})
} else if part.Type == dto.ContentTypeFile {
if part.GetFile().FileId != "" {
return nil, fmt.Errorf("only base64 file is supported in gemini")
}
fileSource := types.NewBase64FileSource(part.GetFile().FileData, "")
base64Data, mimeType, err := service.GetBase64Data(c, fileSource, "formatting file for Gemini")
if err != nil {
return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
})
} else if part.Type == dto.ContentTypeInputAudio {
if part.GetInputAudio().Data == "" {
return nil, fmt.Errorf("only base64 audio is supported in gemini")
}
audioSource := types.NewBase64FileSource(part.GetInputAudio().Data, "audio/"+part.GetInputAudio().Format)
base64Data, mimeType, err := service.GetBase64Data(c, audioSource, "formatting audio for Gemini")
if err != nil {
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
+7 -1
View File
@@ -78,7 +78,10 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
return request, nil
if info.RelayMode != constant.RelayModeImagesGenerations {
return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
}
return oaiImage2MiniMaxImageRequest(request), nil
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
@@ -121,6 +124,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
if info.RelayMode == constant.RelayModeAudioSpeech {
return handleTTSResponse(c, resp, info)
}
if info.RelayMode == constant.RelayModeImagesGenerations {
return miniMaxImageHandler(c, resp, info)
}
switch info.RelayFormat {
case types.RelayFormatClaude:
+137
View File
@@ -0,0 +1,137 @@
package minimax
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/gin-gonic/gin"
)
func TestGetRequestURLForImageGeneration(t *testing.T) {
t.Parallel()
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
ChannelMeta: &relaycommon.ChannelMeta{
ChannelBaseUrl: "https://api.minimax.chat",
},
}
got, err := GetRequestURL(info)
if err != nil {
t.Fatalf("GetRequestURL returned error: %v", err)
}
want := "https://api.minimax.chat/v1/image_generation"
if got != want {
t.Fatalf("GetRequestURL() = %q, want %q", got, want)
}
}
func TestConvertImageRequest(t *testing.T) {
t.Parallel()
adaptor := &Adaptor{}
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
OriginModelName: "image-01",
}
request := dto.ImageRequest{
Model: "image-01",
Prompt: "a red fox in snowfall",
Size: "1536x1024",
ResponseFormat: "url",
N: uintPtr(2),
}
got, err := adaptor.ConvertImageRequest(gin.CreateTestContextOnly(httptest.NewRecorder(), gin.New()), info, request)
if err != nil {
t.Fatalf("ConvertImageRequest returned error: %v", err)
}
body, err := json.Marshal(got)
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
if payload["model"] != "image-01" {
t.Fatalf("model = %#v, want %q", payload["model"], "image-01")
}
if payload["prompt"] != request.Prompt {
t.Fatalf("prompt = %#v, want %q", payload["prompt"], request.Prompt)
}
if payload["n"] != float64(2) {
t.Fatalf("n = %#v, want 2", payload["n"])
}
if payload["aspect_ratio"] != "3:2" {
t.Fatalf("aspect_ratio = %#v, want %q", payload["aspect_ratio"], "3:2")
}
if payload["response_format"] != "url" {
t.Fatalf("response_format = %#v, want %q", payload["response_format"], "url")
}
}
func TestDoResponseForImageGeneration(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
StartTime: time.Unix(1700000000, 0),
}
resp := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: httptest.NewRecorder().Result().Body,
}
resp.Body = ioNopCloser(`{"data":{"image_urls":["https://example.com/minimax.png"]}}`)
adaptor := &Adaptor{}
usage, err := adaptor.DoResponse(c, resp, info)
if err != nil {
t.Fatalf("DoResponse returned error: %v", err)
}
if usage == nil {
t.Fatalf("DoResponse returned nil usage")
}
body := recorder.Body.String()
if !strings.Contains(body, `"url":"https://example.com/minimax.png"`) {
t.Fatalf("response body = %s, want OpenAI image response with image URL", body)
}
if strings.Contains(body, `"image_urls"`) {
t.Fatalf("response body = %s, should not expose raw MiniMax image_urls payload", body)
}
}
type nopReadCloser struct {
*strings.Reader
}
func (n nopReadCloser) Close() error {
return nil
}
func ioNopCloser(body string) nopReadCloser {
return nopReadCloser{Reader: strings.NewReader(body)}
}
func uintPtr(v uint) *uint {
return &v
}
+4
View File
@@ -8,6 +8,8 @@ var ModelList = []string{
"abab6-chat",
"abab5.5-chat",
"abab5.5s-chat",
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"speech-2.5-hd-preview",
"speech-2.5-turbo-preview",
"speech-02-hd",
@@ -19,6 +21,8 @@ var ModelList = []string{
"MiniMax-M2",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
"image-01",
"image-01-live",
}
var ChannelName = "minimax"
+213
View File
@@ -0,0 +1,213 @@
package minimax
import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
type MiniMaxImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
AspectRatio string `json:"aspect_ratio,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
N int `json:"n,omitempty"`
PromptOptimizer *bool `json:"prompt_optimizer,omitempty"`
AigcWatermark *bool `json:"aigc_watermark,omitempty"`
}
type MiniMaxImageResponse struct {
ID string `json:"id"`
Data struct {
ImageURLs []string `json:"image_urls"`
ImageBase64 []string `json:"image_base64"`
} `json:"data"`
Metadata map[string]any `json:"metadata"`
BaseResp struct {
StatusCode int `json:"status_code"`
StatusMsg string `json:"status_msg"`
} `json:"base_resp"`
}
func oaiImage2MiniMaxImageRequest(request dto.ImageRequest) MiniMaxImageRequest {
responseFormat := normalizeMiniMaxResponseFormat(request.ResponseFormat)
minimaxRequest := MiniMaxImageRequest{
Model: request.Model,
Prompt: request.Prompt,
ResponseFormat: responseFormat,
N: 1,
AigcWatermark: request.Watermark,
}
if request.Model == "" {
minimaxRequest.Model = "image-01"
}
if request.N != nil && *request.N > 0 {
minimaxRequest.N = int(*request.N)
}
if aspectRatio := aspectRatioFromImageRequest(request); aspectRatio != "" {
minimaxRequest.AspectRatio = aspectRatio
}
if raw, ok := request.Extra["prompt_optimizer"]; ok {
var promptOptimizer bool
if err := common.Unmarshal(raw, &promptOptimizer); err == nil {
minimaxRequest.PromptOptimizer = &promptOptimizer
}
}
return minimaxRequest
}
func aspectRatioFromImageRequest(request dto.ImageRequest) string {
if raw, ok := request.Extra["aspect_ratio"]; ok {
var aspectRatio string
if err := common.Unmarshal(raw, &aspectRatio); err == nil && aspectRatio != "" {
return aspectRatio
}
}
switch request.Size {
case "1024x1024":
return "1:1"
case "1792x1024":
return "16:9"
case "1024x1792":
return "9:16"
case "1536x1024", "1248x832":
return "3:2"
case "1024x1536", "832x1248":
return "2:3"
case "1152x864":
return "4:3"
case "864x1152":
return "3:4"
case "1344x576":
return "21:9"
}
width, height, ok := parseImageSize(request.Size)
if !ok {
return ""
}
ratio := reduceAspectRatio(width, height)
switch ratio {
case "1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9":
return ratio
default:
return ""
}
}
func parseImageSize(size string) (int, int, bool) {
parts := strings.Split(size, "x")
if len(parts) != 2 {
return 0, 0, false
}
width, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, false
}
height, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, false
}
if width <= 0 || height <= 0 {
return 0, 0, false
}
return width, height, true
}
func reduceAspectRatio(width, height int) string {
divisor := gcd(width, height)
return fmt.Sprintf("%d:%d", width/divisor, height/divisor)
}
func gcd(a, b int) int {
for b != 0 {
a, b = b, a%b
}
if a == 0 {
return 1
}
return a
}
func normalizeMiniMaxResponseFormat(responseFormat string) string {
switch strings.ToLower(responseFormat) {
case "", "url":
return "url"
case "b64_json", "base64":
return "base64"
default:
return responseFormat
}
}
func responseMiniMax2OpenAIImage(response *MiniMaxImageResponse, info *relaycommon.RelayInfo) (*dto.ImageResponse, error) {
imageResponse := &dto.ImageResponse{
Created: info.StartTime.Unix(),
}
for _, imageURL := range response.Data.ImageURLs {
imageResponse.Data = append(imageResponse.Data, dto.ImageData{Url: imageURL})
}
for _, imageBase64 := range response.Data.ImageBase64 {
imageResponse.Data = append(imageResponse.Data, dto.ImageData{B64Json: imageBase64})
}
if len(response.Metadata) > 0 {
metadata, err := common.Marshal(response.Metadata)
if err != nil {
return nil, err
}
imageResponse.Metadata = metadata
}
return imageResponse, nil
}
func miniMaxImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
}
service.CloseResponseBodyGracefully(resp)
var minimaxResponse MiniMaxImageResponse
if err := common.Unmarshal(responseBody, &minimaxResponse); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if minimaxResponse.BaseResp.StatusCode != 0 {
return nil, types.WithOpenAIError(types.OpenAIError{
Message: minimaxResponse.BaseResp.StatusMsg,
Type: "minimax_image_error",
Code: fmt.Sprintf("%d", minimaxResponse.BaseResp.StatusCode),
}, resp.StatusCode)
}
openAIResponse, err := responseMiniMax2OpenAIImage(&minimaxResponse, info)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
jsonResponse, err := common.Marshal(openAIResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
if _, err := c.Writer.Write(jsonResponse); err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
return &dto.Usage{}, nil
}
+2
View File
@@ -21,6 +21,8 @@ func GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
switch info.RelayMode {
case constant.RelayModeChatCompletions:
return fmt.Sprintf("%s/v1/text/chatcompletion_v2", baseUrl), nil
case constant.RelayModeImagesGenerations:
return fmt.Sprintf("%s/v1/image_generation", baseUrl), nil
case constant.RelayModeAudioSpeech:
return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil
default:
+2 -9
View File
@@ -98,15 +98,8 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
parts := m.ParseContent()
for _, part := range parts {
if part.Type == dto.ContentTypeImageURL {
img := part.GetImageMedia()
if img != nil && img.Url != "" {
// 使用统一的文件服务获取图片数据
var source *types.FileSource
if strings.HasPrefix(img.Url, "http") {
source = types.NewURLFileSource(img.Url)
} else {
source = types.NewBase64FileSource(img.Url, "")
}
source := part.ToFileSource()
if source != nil {
base64Data, _, err := service.GetBase64Data(c, source, "fetch image for ollama chat")
if err != nil {
return nil, err
+8 -3
View File
@@ -136,8 +136,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
task = "chat/completions" + task
}
// 特殊处理 responses API
if info.RelayMode == relayconstant.RelayModeResponses {
// 特殊处理 responses API(包含 compact
if info.RelayMode == relayconstant.RelayModeResponses || info.RelayMode == relayconstant.RelayModeResponsesCompact {
responsesApiVersion := "preview"
subUrl := "/openai/v1/responses"
@@ -150,6 +150,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
responsesApiVersion = info.ChannelOtherSettings.AzureResponsesVersion
}
// compact 模式追加 /compact
if info.RelayMode == relayconstant.RelayModeResponsesCompact {
subUrl = subUrl + "/compact"
}
requestURL = fmt.Sprintf("%s?api-version=%s", subUrl, responsesApiVersion)
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, requestURL, info.ChannelType), nil
}
@@ -369,7 +374,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
a.ResponseFormat = request.ResponseFormat
if info.RelayMode == relayconstant.RelayModeAudioSpeech {
jsonData, err := json.Marshal(request)
jsonData, err := common.Marshal(request)
if err != nil {
return nil, fmt.Errorf("error marshalling object: %w", err)
}
+3 -3
View File
@@ -80,9 +80,9 @@ type AliVideoOutput struct {
// AliUsage 使用统计
type AliUsage struct {
Duration int `json:"duration,omitempty"`
VideoCount int `json:"video_count,omitempty"`
SR int `json:"SR,omitempty"`
Duration dto.IntValue `json:"duration,omitempty"`
VideoCount dto.IntValue `json:"video_count,omitempty"`
SR dto.IntValue `json:"SR,omitempty"`
}
type AliMetadata struct {
+43
View File
@@ -132,6 +132,49 @@ func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *r
return nil
}
// EstimateBilling 检测请求 metadata 中是否包含视频输入,返回视频折扣 OtherRatio。
func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
req, err := relaycommon.GetTaskRequest(c)
if err != nil {
return nil
}
if hasVideoInMetadata(req.Metadata) {
if ratio, ok := GetVideoInputRatio(info.OriginModelName); ok {
return map[string]float64{"video_input": ratio}
}
}
return nil
}
// hasVideoInMetadata 直接检查 metadata 的 content 数组是否包含 video_url 条目,
// 避免构建完整的上游 requestPayload。
func hasVideoInMetadata(metadata map[string]interface{}) bool {
if metadata == nil {
return false
}
contentRaw, ok := metadata["content"]
if !ok {
return false
}
contentSlice, ok := contentRaw.([]interface{})
if !ok {
return false
}
for _, item := range contentSlice {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
if itemMap["type"] == "video_url" {
return true
}
if _, has := itemMap["video_url"]; has {
return true
}
}
return false
}
// BuildRequestBody converts request into Doubao specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
req, err := relaycommon.GetTaskRequest(c)
+13
View File
@@ -10,3 +10,16 @@ var ModelList = []string{
}
var ChannelName = "doubao-video"
// videoInputRatioMap 视频输入折扣比率(含视频单价 / 不含视频单价)。
// 管理员应将 ModelRatio 设置为"不含视频"的较高费率,
// 系统在检测到视频输入时自动乘以此折扣。
var videoInputRatioMap = map[string]float64{
"doubao-seedance-2-0-260128": 28.0 / 46.0, // ~0.6087
"doubao-seedance-2-0-fast-260128": 22.0 / 37.0, // ~0.5946
}
func GetVideoInputRatio(modelName string) (float64, bool) {
r, ok := videoInputRatioMap[modelName]
return r, ok
}
+1
View File
@@ -44,6 +44,7 @@ var claudeModelMap = map[string]string{
"claude-haiku-4-5-20251001": "claude-haiku-4-5@20251001",
"claude-opus-4-5-20251101": "claude-opus-4-5@20251101",
"claude-opus-4-6": "claude-opus-4-6",
"claude-opus-4-7": "claude-opus-4-7",
}
const anthropicVersion = "vertex-2023-10-16"
+3
View File
@@ -64,6 +64,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
}
return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil
case relayconstant.RelayModeImagesGenerations:
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil
}
return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil
default:
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
+32 -13
View File
@@ -53,30 +53,49 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
}
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" &&
strings.HasPrefix(request.Model, "claude-opus-4-6") {
(strings.HasPrefix(request.Model, "claude-opus-4-6") || strings.HasPrefix(request.Model, "claude-opus-4-7")) {
request.Model = baseModel
request.Thinking = &dto.Thinking{
Type: "adaptive",
}
request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
request.Temperature = common.GetPointer[float64](1.0)
if strings.HasPrefix(request.Model, "claude-opus-4-7") {
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
// and defaults display to "omitted"; restore the 4.6 visible summary.
request.Thinking.Display = "summarized"
request.Temperature = nil
request.TopP = nil
request.TopK = nil
} else {
request.Temperature = common.GetPointer[float64](1.0)
}
info.UpstreamModelName = request.Model
} else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
strings.HasSuffix(request.Model, "-thinking") {
if request.Thinking == nil {
// 因为BudgetTokens 必须大于1024
if request.MaxTokens == nil || *request.MaxTokens < 1280 {
request.MaxTokens = common.GetPointer[uint](1280)
}
baseModel := strings.TrimSuffix(request.Model, "-thinking")
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
request.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
request.OutputConfig = json.RawMessage(`{"effort":"high"}`)
request.Temperature = nil
request.TopP = nil
request.TopK = nil
} else {
// 因为BudgetTokens 必须大于1024
if request.MaxTokens == nil || *request.MaxTokens < 1280 {
request.MaxTokens = common.GetPointer[uint](1280)
}
// BudgetTokens 为 max_tokens 的 80%
request.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: common.GetPointer[int](int(float64(*request.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
// BudgetTokens 为 max_tokens 的 80%
request.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: common.GetPointer[int](int(float64(*request.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
}
// TODO: 临时处理
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
request.Temperature = common.GetPointer[float64](1.0)
}
// TODO: 临时处理
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
request.Temperature = common.GetPointer[float64](1.0)
}
if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
request.Model = strings.TrimSuffix(request.Model, "-thinking")
+1
View File
@@ -32,6 +32,7 @@ var paramOverrideKeyAuditPaths = map[string]struct{}{
"upstream_model": {},
"service_tier": {},
"inference_geo": {},
"speed": {},
}
type paramOverrideAuditRecorder struct {
+19 -1
View File
@@ -2038,6 +2038,8 @@ func TestRemoveDisabledFieldsDefaultFiltering(t *testing.T) {
input := `{
"service_tier":"flex",
"inference_geo":"eu",
"speed":"fast",
"cache_control":{"type":"ephemeral"},
"safety_identifier":"user-123",
"store":true,
"stream_options":{"include_obfuscation":false}
@@ -2048,7 +2050,7 @@ func TestRemoveDisabledFieldsDefaultFiltering(t *testing.T) {
if err != nil {
t.Fatalf("RemoveDisabledFields returned error: %v", err)
}
assertJSONEqual(t, `{"store":true}`, string(out))
assertJSONEqual(t, `{"cache_control":{"type":"ephemeral"},"store":true}`, string(out))
}
func TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) {
@@ -2067,6 +2069,22 @@ func TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) {
assertJSONEqual(t, `{"inference_geo":"eu","store":true}`, string(out))
}
func TestRemoveDisabledFieldsAllowSpeed(t *testing.T) {
input := `{
"speed":"fast",
"store":true
}`
settings := dto.ChannelOtherSettings{
AllowSpeed: true,
}
out, err := RemoveDisabledFields([]byte(input), settings, false)
if err != nil {
t.Fatalf("RemoveDisabledFields returned error: %v", err)
}
assertJSONEqual(t, `{"speed":"fast","store":true}`, string(out))
}
func TestApplyParamOverrideWithRelayInfoRecordsOperationAuditInDebugMode(t *testing.T) {
originalDebugEnabled := common2.DebugEnabled
common2.DebugEnabled = true
+25
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
@@ -437,6 +438,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
if request != nil {
isStream = request.IsStream(c)
}
c.Set(string(constant.ContextKeyIsStream), isStream)
// firstResponseTime = time.Now() - 1 second
@@ -690,6 +692,7 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
type Alias TaskSubmitReq
aux := &struct {
Metadata json.RawMessage `json:"metadata,omitempty"`
Duration json.RawMessage `json:"duration,omitempty"`
*Alias
}{
Alias: (*Alias)(t),
@@ -699,6 +702,20 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
return err
}
if len(aux.Duration) > 0 {
var durationInt int
if err := common.Unmarshal(aux.Duration, &durationInt); err == nil {
t.Duration = durationInt
} else {
var durationStr string
if err := common.Unmarshal(aux.Duration, &durationStr); err == nil && durationStr != "" {
if v, err := strconv.Atoi(durationStr); err == nil {
t.Duration = v
}
}
}
}
if len(aux.Metadata) > 0 {
var metadataStr string
if err := common.Unmarshal(aux.Metadata, &metadataStr); err == nil && metadataStr != "" {
@@ -754,6 +771,7 @@ func FailTaskInfo(reason string) *TaskInfo {
// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段
// service_tier: 服务层级字段,可能导致额外计费(OpenAI、Claude、Responses API 支持)
// inference_geo: Claude 数据驻留推理区域字段(仅 Claude 支持,默认过滤)
// speed: Claude 推理速度模式字段(仅 Claude 支持,默认过滤)
// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用)
// safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私)
// stream_options.include_obfuscation: 响应流混淆控制字段(仅 OpenAI Responses API 支持)
@@ -782,6 +800,13 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther
}
}
// 默认移除 speed,除非明确允许(避免意外切换 Claude 推理速度模式)
if !channelOtherSettings.AllowSpeed {
if _, exists := data["speed"]; exists {
delete(data, "speed")
}
}
// 默认允许 store 透传,除非明确禁用(禁用可能影响 Codex 使用)
if channelOtherSettings.DisableStore {
if _, exists := data["store"]; exists {
+3 -1
View File
@@ -204,7 +204,9 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
if err != nil {
return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true)
}
} else if err := common.UnmarshalBodyReusable(c, &req); err != nil {
}
// 为了metadata字段的兼容性,统一UnmarshalBodyReusable
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
}
+47 -17
View File
@@ -5,6 +5,7 @@ import (
"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/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -13,6 +14,21 @@ import (
"github.com/gin-gonic/gin"
)
func modelPriceNotConfiguredError(modelName string, userId int) error {
if model.IsAdmin(userId) {
return fmt.Errorf(
"模型 %s 的价格未配置。请前往「系统设置 → 运营设置」开启自用模式,或在「系统设置 → 分组与模型定价设置」中为该模型配置价格;"+
"Model %s price not configured. Go to System Settings → Operation Settings to enable self-use mode, or configure the model price in System Settings → Group & Model Pricing.",
modelName, modelName,
)
}
return fmt.Errorf(
"模型 %s 的价格尚未由管理员配置,暂时无法使用,请联系站点管理员开启该模型;"+
"Model %s has not been priced by the administrator yet. Please contact the site administrator to enable this model.",
modelName, modelName,
)
}
// https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration
const claudeCacheCreation1hMultiplier = 6 / 3.75
@@ -75,7 +91,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
acceptUnsetRatio = true
}
if !acceptUnsetRatio {
return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
return types.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
}
}
completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName)
@@ -139,47 +155,61 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
return priceData, nil
}
// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task)
// ModelPriceHelperPerCall 按次/按量计费的 PriceHelper (MJ、Task)
func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) {
groupRatioInfo := HandleGroupRatio(c, info)
modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
// 如果没有配置价格,检查模型倍率配置
if !success {
usePrice := success
var modelRatio float64
// 没有配置费用,也要使用默认费用,否则按费率计费模型无法使用
if !success {
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName]
if ok {
modelPrice = defaultPrice
usePrice = true
} else {
// 没有配置倍率也不接受没配置,那就返回错误
_, ratioSuccess, matchName := ratio_setting.GetModelRatio(info.OriginModelName)
var ratioSuccess bool
var matchName string
modelRatio, ratioSuccess, matchName = ratio_setting.GetModelRatio(info.OriginModelName)
acceptUnsetRatio := false
if info.UserSetting.AcceptUnsetRatioModel {
acceptUnsetRatio = true
}
if !ratioSuccess && !acceptUnsetRatio {
return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
return types.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
}
// 未配置价格但配置了倍率,使用默认预扣价格
modelPrice = float64(common.PreConsumedQuota) / common.QuotaPerUnit
}
}
quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
// 免费模型检测(与 ModelPriceHelper 对齐)
var quota int
freeModel := false
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
quota = 0
freeModel = true
if usePrice {
quota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
quota = 0
freeModel = true
}
}
} else {
// 按量计费:以模型倍率的一半作为预扣额度
quota = int(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
modelPrice = -1
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelRatio == 0 {
quota = 0
freeModel = true
}
}
}
priceData := types.PriceData{
FreeModel: freeModel,
ModelPrice: modelPrice,
ModelRatio: modelRatio,
UsePrice: usePrice,
Quota: quota,
GroupRatioInfo: groupRatioInfo,
}
+1 -1
View File
@@ -143,7 +143,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if err != nil {
info.OriginModelName = originModelName
info.PriceData = originPriceData
return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry(), types.ErrOptionWithStatusCode(http.StatusBadRequest))
}
service.PostTextConsumeQuota(c, info, usageDto, nil)
+6
View File
@@ -49,6 +49,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
apiRouter.POST("/creem/webhook", controller.CreemWebhook)
apiRouter.POST("/waffo/webhook", controller.WaffoWebhook)
//apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook)
// Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
@@ -90,7 +91,10 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay)
selfRoute.POST("/stripe/amount", controller.RequestStripeAmount)
selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay)
selfRoute.POST("/waffo/amount", controller.RequestWaffoAmount)
selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay)
//selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount)
//selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay)
selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
selfRoute.PUT("/setting", controller.UpdateUserSetting)
@@ -257,6 +261,7 @@ func SetApiRouter(router *gin.Engine) {
tokenRoute.PUT("/", controller.UpdateToken)
tokenRoute.DELETE("/:id", controller.DeleteToken)
tokenRoute.POST("/batch", controller.DeleteTokenBatch)
tokenRoute.POST("/batch/keys", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKeysBatch)
}
usageRoute := apiRouter.Group("/usage")
@@ -292,6 +297,7 @@ func SetApiRouter(router *gin.Engine) {
dataRoute := apiRouter.Group("/data")
dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates)
dataRoute.GET("/users", middleware.AdminAuth(), controller.GetQuotaDatesByUser)
dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates)
logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
+1 -38
View File
@@ -2,11 +2,9 @@ package service
import (
"fmt"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
@@ -44,7 +42,7 @@ func EnableChannel(channelId int, usingKey string, channelName string) {
}
}
func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool {
func ShouldDisableChannel(err *types.NewAPIError) bool {
if !common.AutomaticDisableChannelEnabled {
return false
}
@@ -60,41 +58,6 @@ func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool {
if operation_setting.ShouldDisableByStatusCode(err.StatusCode) {
return true
}
//if err.StatusCode == http.StatusUnauthorized {
// return true
//}
if err.StatusCode == http.StatusForbidden {
switch channelType {
case constant.ChannelTypeGemini:
return true
}
}
oaiErr := err.ToOpenAIError()
switch oaiErr.Code {
case "invalid_api_key":
return true
case "account_deactivated":
return true
case "billing_not_active":
return true
case "pre_consume_token_quota_failed":
return true
case "Arrearage":
return true
}
switch oaiErr.Type {
case "insufficient_quota":
return true
case "insufficient_user_quota":
return true
// https://docs.anthropic.com/claude/reference/errors
case "authentication_error":
return true
case "permission_error":
return true
case "forbidden":
return true
}
lowerMessage := strings.ToLower(err.Error())
search, _ := AcSearch(lowerMessage, operation_setting.AutomaticDisableKeywords, true)
+17 -4
View File
@@ -166,12 +166,22 @@ func GetChannelAffinityCacheStats() ChannelAffinityCacheStats {
unknown++
continue
}
if rule.IncludeUsingGroup {
if rule.IncludeModelName {
if len(parts) < 3 {
unknown++
continue
}
}
if rule.IncludeUsingGroup {
minParts := 3
if rule.IncludeModelName {
minParts = 4
}
if len(parts) < minParts {
unknown++
continue
}
}
byRuleName[ruleName]++
}
@@ -319,11 +329,14 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf
}
}
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, usingGroup string, affinityValue string) string {
parts := make([]string, 0, 3)
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string {
parts := make([]string, 0, 4)
if rule.IncludeRuleName && rule.Name != "" {
parts = append(parts, rule.Name)
}
if rule.IncludeModelName && modelName != "" {
parts = append(parts, modelName)
}
if rule.IncludeUsingGroup && usingGroup != "" {
parts = append(parts, usingGroup)
}
@@ -573,7 +586,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
if ttlSeconds <= 0 {
ttlSeconds = setting.DefaultTTLSeconds
}
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, usingGroup, affinityValue)
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, modelName, usingGroup, affinityValue)
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
setChannelAffinityContext(c, channelAffinityMeta{
CacheKey: cacheKeyFull,
+1 -1
View File
@@ -193,7 +193,7 @@ func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
require.NotNil(t, codexRule)
affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano())
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "default", affinityValue)
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "gpt-5", "default", affinityValue)
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
+9 -1
View File
@@ -28,6 +28,10 @@ var (
codexCredentialRefreshRunning atomic.Bool
)
func shouldAutoRefreshCodexChannelStatus(status int) bool {
return status == common.ChannelStatusEnabled || status == common.ChannelStatusAutoDisabled
}
func StartCodexCredentialAutoRefreshTask() {
codexCredentialRefreshOnce.Do(func() {
if !common.IsMasterNode {
@@ -65,7 +69,11 @@ func runCodexCredentialAutoRefreshOnce() {
var channels []*model.Channel
err := model.DB.
Select("id", "name", "key", "status", "channel_info").
Where("type = ? AND status = 1", constant.ChannelTypeCodex).
Where("type = ? AND (status = ? OR status = ?)",
constant.ChannelTypeCodex,
common.ChannelStatusEnabled,
common.ChannelStatusAutoDisabled,
).
Order("id asc").
Limit(codexCredentialRefreshBatchSize).
Offset(offset).
+37 -15
View File
@@ -227,21 +227,31 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
if oaiUsage == nil {
return nil
}
cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
oaiUsage.PromptTokensDetails.CachedCreationTokens,
oaiUsage.ClaudeCacheCreation5mTokens,
oaiUsage.ClaudeCacheCreation1hTokens,
)
usage := &dto.ClaudeUsage{
InputTokens: oaiUsage.PromptTokens,
OutputTokens: oaiUsage.CompletionTokens,
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
}
if oaiUsage.ClaudeCacheCreation5mTokens > 0 || oaiUsage.ClaudeCacheCreation1hTokens > 0 {
if cacheCreation5m > 0 || cacheCreation1h > 0 {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: oaiUsage.ClaudeCacheCreation5mTokens,
Ephemeral1hInputTokens: oaiUsage.ClaudeCacheCreation1hTokens,
Ephemeral5mInputTokens: cacheCreation5m,
Ephemeral1hInputTokens: cacheCreation1h,
}
}
return usage
}
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
remainder := lo.Max([]int{totalTokens - tokens5m - tokens1h, 0})
return tokens5m + remainder, tokens1h
}
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
if info.ClaudeConvertInfo.Done {
return nil
@@ -426,23 +436,28 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}
if len(openAIResponse.Choices) == 0 {
// no choices
// 可能为非标准的 OpenAI 响应,判断是否已经完成
if info.ClaudeConvertInfo.Done {
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = info.ClaudeConvertInfo.Usage
}
if oaiUsage != nil {
stopOpenBlocks()
oaiUsage := info.ClaudeConvertInfo.Usage
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
},
})
stopReason := stopReasonOpenAI2Claude(info.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReason),
},
})
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
info.ClaudeConvertInfo.Done = true
}
return claudeResponses
} else {
@@ -450,6 +465,13 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
if doneChunk {
info.FinishReason = *chosenChoice.FinishReason
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = info.ClaudeConvertInfo.Usage
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
// Defer closing until usage is available so the final message_delta carries it.
return claudeResponses
}
}
var claudeResponse dto.ClaudeResponse
+53 -32
View File
@@ -25,14 +25,26 @@ import (
// FileService 统一的文件处理服务
// 提供文件下载、解码、缓存等功能的统一入口
// getContextCacheKey 生成 context 缓存的 key
// getContextCacheKey 生成 URL context 缓存的 key
func getContextCacheKey(url string) string {
return fmt.Sprintf("file_cache_%s", common.GenerateHMAC(url))
}
// getBase64ContextCacheKey 生成 base64 context 缓存的 key
// 使用 length + MIME + 前 128 字符作为输入,避免对整个 base64 数据做 hash
func getBase64ContextCacheKey(data string, mimeType string) string {
keyMaterial := fmt.Sprintf("%d:%s:", len(data), mimeType)
if len(data) > 128 {
keyMaterial += data[:128]
} else {
keyMaterial += data
}
return fmt.Sprintf("b64_cache_%s", common.GenerateHMAC(keyMaterial))
}
// LoadFileSource 加载文件源数据
// 这是统一的入口,会自动处理缓存和不同的来源类型
func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string) (*types.CachedFileData, error) {
func LoadFileSource(c *gin.Context, source types.FileSource, reason ...string) (*types.CachedFileData, error) {
if source == nil {
return nil, fmt.Errorf("file source is nil")
}
@@ -43,7 +55,6 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
// 1. 快速检查内部缓存
if source.HasCache() {
// 即使命中内部缓存,也要确保注册到清理列表(如果尚未注册)
if c != nil {
registerSourceForCleanup(c, source)
}
@@ -62,39 +73,49 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
return source.GetCache(), nil
}
// 4. 如果是 URL,检查 Context 缓存
var contextKey string
if source.IsURL() && c != nil {
contextKey = getContextCacheKey(source.URL)
if cachedData, exists := c.Get(contextKey); exists {
data := cachedData.(*types.CachedFileData)
source.SetCache(data)
registerSourceForCleanup(c, source)
return data, nil
}
}
// 5. 执行加载逻辑
// 4. 根据来源类型加载(含 URL context 缓存查找)
var cachedData *types.CachedFileData
var contextKey string
var err error
if source.IsURL() {
cachedData, err = loadFromURL(c, source.URL, reason...)
} else {
cachedData, err = loadFromBase64(source.Base64Data, source.MimeType)
switch s := source.(type) {
case *types.URLSource:
if c != nil {
contextKey = getContextCacheKey(s.URL)
if cached, exists := c.Get(contextKey); exists {
data := cached.(*types.CachedFileData)
source.SetCache(data)
registerSourceForCleanup(c, source)
return data, nil
}
}
cachedData, err = loadFromURL(c, s.URL, reason...)
case *types.Base64Source:
if c != nil {
contextKey = getBase64ContextCacheKey(s.Base64Data, s.MimeType)
if cached, exists := c.Get(contextKey); exists {
data := cached.(*types.CachedFileData)
source.SetCache(data)
registerSourceForCleanup(c, source)
return data, nil
}
}
cachedData, err = loadFromBase64(s.Base64Data, s.MimeType)
default:
return nil, fmt.Errorf("unsupported file source type: %T", source)
}
if err != nil {
return nil, err
}
// 6. 设置缓存
// 5. 设置缓存
source.SetCache(cachedData)
if contextKey != "" && c != nil {
c.Set(contextKey, cachedData)
}
// 7. 注册到 context 以便请求结束时自动清理
// 6. 注册到 context 以便请求结束时自动清理
if c != nil {
registerSourceForCleanup(c, source)
}
@@ -103,15 +124,15 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
}
// registerSourceForCleanup 注册 FileSource 到 context 以便请求结束时清理
func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
func registerSourceForCleanup(c *gin.Context, source types.FileSource) {
if source.IsRegistered() {
return
}
key := string(constant.ContextKeyFileSourcesToCleanup)
var sources []*types.FileSource
var sources []types.FileSource
if existing, exists := c.Get(key); exists {
sources = existing.([]*types.FileSource)
sources = existing.([]types.FileSource)
}
sources = append(sources, source)
c.Set(key, sources)
@@ -123,12 +144,12 @@ func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
func CleanupFileSources(c *gin.Context) {
key := string(constant.ContextKeyFileSourcesToCleanup)
if sources, exists := c.Get(key); exists {
for _, source := range sources.([]*types.FileSource) {
for _, source := range sources.([]types.FileSource) {
if cache := source.GetCache(); cache != nil {
cache.Close()
}
}
c.Set(key, nil) // 清除引用
c.Set(key, nil)
}
}
@@ -363,7 +384,7 @@ func loadFromBase64(base64String string, providedMimeType string) (*types.Cached
}
// GetImageConfig 获取图片配置
func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, string, error) {
func GetImageConfig(c *gin.Context, source types.FileSource) (image.Config, string, error) {
cachedData, err := LoadFileSource(c, source, "get_image_config")
if err != nil {
return image.Config{}, "", err
@@ -394,7 +415,7 @@ func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, str
}
// GetBase64Data 获取 base64 编码的数据
func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (string, string, error) {
func GetBase64Data(c *gin.Context, source types.FileSource, reason ...string) (string, string, error) {
cachedData, err := LoadFileSource(c, source, reason...)
if err != nil {
return "", "", err
@@ -407,13 +428,13 @@ func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (
}
// GetMimeType 获取文件的 MIME 类型
func GetMimeType(c *gin.Context, source *types.FileSource) (string, error) {
func GetMimeType(c *gin.Context, source types.FileSource) (string, error) {
if source.HasCache() {
return source.GetCache().MimeType, nil
}
if source.IsURL() {
mimeType, err := GetFileTypeFromUrl(c, source.URL, "get_mime_type")
if urlSource, ok := source.(*types.URLSource); ok {
mimeType, err := GetFileTypeFromUrl(c, urlSource.URL, "get_mime_type")
if err == nil && mimeType != "" && mimeType != "application/octet-stream" {
return mimeType, nil
}
+2 -2
View File
@@ -37,7 +37,7 @@ func (w *WalletFunding) PreConsume(amount int) error {
if amount <= 0 {
return nil
}
if err := model.DecreaseUserQuota(w.userId, amount); err != nil {
if err := model.DecreaseUserQuota(w.userId, amount, false); err != nil {
return err
}
w.consumed = amount
@@ -49,7 +49,7 @@ func (w *WalletFunding) Settle(delta int) error {
return nil
}
if delta > 0 {
return model.DecreaseUserQuota(w.userId, delta)
return model.DecreaseUserQuota(w.userId, delta, false)
}
return model.IncreaseUserQuota(w.userId, -delta, false)
}
+1 -1
View File
@@ -381,7 +381,7 @@ func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQu
} else {
// Wallet
if quota > 0 {
err = model.DecreaseUserQuota(relayInfo.UserId, quota)
err = model.DecreaseUserQuota(relayInfo.UserId, quota, false)
} else {
err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
}
+21 -5
View File
@@ -36,8 +36,12 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
}
}
other := make(map[string]interface{})
other["is_task"] = true
other["request_path"] = c.Request.URL.Path
other["model_price"] = info.PriceData.ModelPrice
if info.PriceData.ModelRatio > 0 {
other["model_ratio"] = info.PriceData.ModelRatio
}
other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio
if info.PriceData.GroupRatioInfo.HasSpecialRatio {
other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio
@@ -86,7 +90,7 @@ func taskAdjustFunding(task *model.Task, delta int) error {
return model.PostConsumeUserSubscriptionDelta(task.PrivateData.SubscriptionId, int64(delta))
}
if delta > 0 {
return model.DecreaseUserQuota(task.UserId, delta)
return model.DecreaseUserQuota(task.UserId, delta, false)
}
return model.IncreaseUserQuota(task.UserId, -delta, false)
}
@@ -117,6 +121,9 @@ func taskBillingOther(task *model.Task) map[string]interface{} {
other := make(map[string]interface{})
if bc := task.PrivateData.BillingContext; bc != nil {
other["model_price"] = bc.ModelPrice
if bc.ModelRatio > 0 {
other["model_ratio"] = bc.ModelRatio
}
other["group_ratio"] = bc.GroupRatio
if len(bc.OtherRatios) > 0 {
for k, v := range bc.OtherRatios {
@@ -222,7 +229,6 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
}
other := taskBillingOther(task)
other["task_id"] = task.TaskID
//other["reason"] = reason
other["pre_consumed_quota"] = preConsumedQuota
other["actual_quota"] = actualQuota
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
@@ -277,9 +283,19 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
finalGroupRatio = groupRatio
}
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio)
// 计算 OtherRatios 乘积(视频折扣、时长等)
otherMultiplier := 1.0
if bc := task.PrivateData.BillingContext; bc != nil {
for _, r := range bc.OtherRatios {
if r != 1.0 && r > 0 {
otherMultiplier *= r
}
}
}
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f", totalTokens, modelRatio, finalGroupRatio)
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * otherMultiplier
actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio * otherMultiplier)
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, otherMultiplier=%.4f", totalTokens, modelRatio, finalGroupRatio, otherMultiplier)
RecalculateTaskQuota(ctx, task, actualQuota, reason)
}

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