Skip to content

fix(topup): add currency symbol to amounts in RechargeCard - #1952

Merged
Calcium-Ion merged 25 commits into
QuantumNous:alphafrom
kyubibii:main
Oct 3, 2025
Merged

fix(topup): add currency symbol to amounts in RechargeCard#1952
Calcium-Ion merged 25 commits into
QuantumNous:alphafrom
kyubibii:main

Conversation

@kyubibii

@kyubibii kyubibii commented Oct 2, 2025

Copy link
Copy Markdown
Contributor

PR 类型

  • Bug 修复
  • 新功能
  • 文档更新
  • 其他

PR 是否包含破坏性更新?

PR 描述

请在下方详细描述您的 PR,包括目的、实现细节等。

重要提示

所有 PR 都必须提交到 alpha 分支。请确保您的 PR 目标分支是 alpha

Summary by CodeRabbit

  • New Features

    • Added User Agreement and Privacy Policy pages and routes; admins can edit these from settings.
    • Registration now requires user consent when agreements/policies are present.
    • New channel option "豆包视频 (Doubao Video)" appears in channel lists and icons.
  • Documentation

    • README updated with Japanese link, Google Gemini and Claude API mentions, and payment gateway notes (EPay, Stripe).
  • Style / Content

    • Footer links updated; extensive i18n entries added (including Japanese).

t0ng7u and others added 7 commits September 27, 2025 18:47
…onent pages

- Replace blanket console route footer hiding with specific page targeting
- Only hide footer on pages that use CardPro component:
  * /console/channel (channels management)
  * /console/log (usage logs)
  * /console/redemption (redemption codes)
  * /console/user (user management)
  * /console/token (token management)
  * /console/midjourney (midjourney logs)
  * /console/task (task logs)
  * /console/models (model management)
  * /pricing (pricing page)
- Footer now displays on other console pages (dashboard, settings, topup, etc.)
- Improves UI consistency by showing footer where CardPro's internal pagination isn't used

This change ensures footer is only hidden when CardPro component provides its own
pagination/footer functionality, while preserving footer visibility on other pages
that benefit from the global footer navigation.
- What: 在充值卡显示的实付与节省金额前加入美元符号 `$`。
- Why: 满足 issue #1881,要求在金额前标注货币单位以减少歧义。
- Files: src/components/topup/RechargeCard.jsx
- Note: 这是局部修复。建议后续实现统一的 currency formatter(Intl.NumberFormat)并从后端/配置读取货币代码以支持本地化与多币种。

Closes #1881
@coderabbitai

coderabbitai Bot commented Oct 2, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds Doubao (豆包) video channel and task adaptor, token-aware post-task billing adjustments for video tasks, new user agreement & privacy policy storage/endpoints and frontend pages/consent flows, content-type utility, header override helper and Claude header commonization, PostgreSQL in docker-compose, Go tool/dependency bumps, extensive i18n/docs updates, and multiple UI/formatting edits.

Changes

Cohort / File(s) Summary
New Doubao video channel (backend)
constant/channel.go, relay/channel/task/doubao/constants.go, relay/channel/task/doubao/adaptor.go, relay/relay_adaptor.go, router/api-router.go, controller/channel-test.go
Adds ChannelTypeDoubaoVideo and base URL; introduces doubao task adaptor with request/response DTOs, task polling, parsing, and model list; plugs adaptor into relay factory and router; adds explicit unsupported test branch for doubao video.
Task billing adjustments (backend)
controller/task_video.go
After successful video task, computes actualQuota from total_tokens and model/group ratios, then charges or refunds user/channel quotas and logs adjustments.
Relay header & Claude handling (backend)
relay/channel/api_request.go, relay/channel/aws/adaptor.go, relay/channel/claude/adaptor.go
Adds processHeaderOverride helper to apply header overrides with {api_key} substitution; centralizes Claude header logic into CommonClaudeHeadersOperation and updates adaptors to call it.
Task info schema & options storage (backend)
relay/common/relay_info.go, model/option.go
Extends TaskInfo with CompletionTokens and TotalTokens; adds OptionMap keys UserAgreement and PrivacyPolicy.
New misc handlers & routes (backend)
controller/misc.go, router/api-router.go
Adds GetUserAgreement and GetPrivacyPolicy handlers and corresponding public GET routes.
Go toolchain & deps, compose (infra)
go.mod, docker-compose.yml
Bumps Go version to 1.25.1 and updates indirect dependencies; replaces MySQL with PostgreSQL service and updates SQL_DSN/volumes/depends_on and healthcheck.
Content detection & frontend pages
web/src/utils/contentDetector.js, web/src/pages/UserAgreement/index.jsx, web/src/pages/PrivacyPolicy/index.jsx, web/src/App.jsx
Adds content-type detectors (url/html/markdown/text); adds UserAgreement and PrivacyPolicy pages with caching, dynamic asset injection, and routing; registers lazy routes.
Frontend consent flow & settings
web/src/components/auth/RegisterForm.jsx, web/src/components/settings/OtherSetting.jsx, web/src/components/layout/Footer.jsx, web/src/components/layout/PageLayout.jsx, web/src/pages/Setting/*
Fetches term availability, adds consent checkbox gating registration, UI for editing UserAgreement/PrivacyPolicy in settings, footer link updates, and footer visibility whitelist refactor.
Frontend Doubao support & constants
web/src/constants/channel.constants.js, web/src/helpers/render.jsx, web/src/components/table/channels/modals/EditChannelModal.jsx
Adds Doubao channel option (value 54) and icon case; UI template/variables adjustments in channel modal.
i18n, locale & README updates
web/src/i18n/locales/{en,fr,zh}.json, README.md, README.en.md, README.fr.md, README.ja.md
Adds many user agreement/privacy policy translations and related UI text; updates READMEs (language link, Gemini/Claude entries, feature list, machine translation note, Japanese README added).
API helpers & misc frontend tweaks
web/src/helpers/api.js, web/src/components/topup/*, web/src/components/common/modals/TwoFactorAuthModal.jsx, web/src/components/table/*, web/src/pages/Setting/Operation/*, web/src/pages/Setting/Ratio/ModelRatioSettings.jsx, web/jsconfig.json, web/src/hooks/common/useSidebar.js, web/src/components/topup/RechargeCard.jsx
Mostly formatting/line-wrap changes and small UX/content edits (topup card formatting, minor logic-preserving JSX reflows); a few mapping/arrow syntax cleanups.
i18n FR heavy edit
web/src/i18n/locales/fr.json
Large addition/remapping of French strings (Passkey/2FA, policy texts); significant locale changes.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Frontend
  participant API as new-api
  participant Upstream as Doubao
  Note over Client,API: Submit Doubao video generation
  Client->>API: POST /v1/video/generations (task submit)
  API->>API: ValidateRequestAndSetAction
  API->>Upstream: BuildRequestBody/POST (Authorization Bearer)
  Upstream-->>API: 200 response with task_id
  API->>Client: 202 accepted (task_id)
  Note over API,Upstream: Polling flow
  loop polling
    API->>Upstream: GET /tasks/{task_id} (auth header)
    Upstream-->>API: task status & total_tokens
    API->>API: ParseTaskResult -> if success and total_tokens>0 compute actualQuota
    API->>API: Adjust user/channel quotas (charge/refund) and log
    API->>Client: Update task status / final result
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • Calcium-Ion
  • creamlike1024

Poem

🐰
I nibble code carrots, hop with glee,
New videos, policies, routes—whee!
Headers tidy, tokens reconciled,
Docs refreshed, locales compiled.
A rabbit clap—this patch is spry, let’s spree!

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Title Check ⚠️ Warning The provided title claims a currency symbol was added in the RechargeCard component, but the diff shows only formatting and line-wrapping changes with no functional update to currency display; thus the title is misleading and does not reflect the actual scope of changes. Please update the pull request title to accurately describe the changes, for example “chore: reformat code and update multilingual documentation and feature flags,” or isolate the intended currency symbol update in RechargeCard into a separate PR so the title matches its content.
Docstring Coverage ⚠️ Warning Docstring coverage is 8.70% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@kyubibii
kyubibii changed the base branch from main to alpha October 2, 2025 04:31

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2938246 and 2290acc.

📒 Files selected for processing (1)
  • web/src/components/topup/RechargeCard.jsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
web/src/components/topup/RechargeCard.jsx (1)
web/src/components/topup/modals/PaymentConfirmModal.jsx (1)
  • hasDiscount (43-43)

Comment on lines 371 to 373
{t('实付')} ${actualPay.toFixed(2)},
{hasDiscount ? `${t('节省')} $${save.toFixed(2)}` : `${t('节省')} $0.00`}
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid hardcoding the USD symbol here

This UI previously respected whatever currency the backend/formatters supplied; forcing a literal “$” now mislabels every non-USD top up (e.g., CNY users will see dollars). Pull the symbol from config/props (e.g., topupInfo?.currency_symbol) or reuse the existing formatter instead of hardcoding.

-                                {t('实付')} ${actualPay.toFixed(2)},
-                                {hasDiscount ? `${t('节省')} $${save.toFixed(2)}` : `${t('节省')} $0.00`}
+                                {t('实付')} {(topupInfo?.currency_symbol ?? topupInfo?.currencySymbol ?? '') + actualPay.toFixed(2)},
+                                {hasDiscount
+                                  ? `${t('节省')} ${(topupInfo?.currency_symbol ?? topupInfo?.currencySymbol ?? '')}${save.toFixed(2)}`
+                                  : `${t('节省')} ${(topupInfo?.currency_symbol ?? topupInfo?.currencySymbol ?? '')}0.00`}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{t('实付')} ${actualPay.toFixed(2)}
{hasDiscount ? `${t('节省')} $${save.toFixed(2)}` : `${t('节省')} $0.00`}
</div>
{t('实付')} {(topupInfo?.currency_symbol ?? topupInfo?.currencySymbol ?? '') + actualPay.toFixed(2)}
{hasDiscount
? `${t('节省')} ${(topupInfo?.currency_symbol ?? topupInfo?.currencySymbol ?? '')}${save.toFixed(2)}`
: `${t('节省')} ${(topupInfo?.currency_symbol ?? topupInfo?.currencySymbol ?? '')}0.00`}
</div>
🤖 Prompt for AI Agents
In web/src/components/topup/RechargeCard.jsx around lines 371-373, the code
hardcodes the USD symbol ("$") when rendering actualPay and save; replace the
literal "$" with the currency symbol provided by data or formatter (e.g., use
topupInfo?.currency_symbol or the existing number formatter) for both actualPay
and save, and add a safe fallback (e.g., an empty string or a default symbol
from config) so non-USD users display the correct currency symbol.

seefs001 and others added 18 commits October 2, 2025 14:41
feat(i18n): add and update French translations
…onent pages

- Replace blanket console route footer hiding with specific page targeting
- Only hide footer on pages that use CardPro component:
  * /console/channel (channels management)
  * /console/log (usage logs)
  * /console/redemption (redemption codes)
  * /console/user (user management)
  * /console/token (token management)
  * /console/midjourney (midjourney logs)
  * /console/task (task logs)
  * /console/models (model management)
  * /pricing (pricing page)
- Footer now displays on other console pages (dashboard, settings, topup, etc.)
- Improves UI consistency by showing footer where CardPro's internal pagination isn't used

This change ensures footer is only hidden when CardPro component provides its own
pagination/footer functionality, while preserving footer visibility on other pages
that benefit from the global footer navigation.
…le-footer

# Conflicts:
#	web/src/components/table/channels/modals/EditChannelModal.jsx
#	web/src/hooks/common/useSidebar.js
- Removed the 'chatnio' link from the footer.
- Added new links for 'CoAI' and 'GPT-Load' in the footer.
- Updated the localization key for '基于New API的项目' to '友情链接' for better clarity.
- Adjusted the design of the footer to improve layout and visibility of the developer credit.
✨ feat(layout): refine footer visibility logic to target CardPro component pages

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
web/src/components/table/channels/modals/EditChannelModal.jsx (1)

462-493: Missing space in Bearer token template.

Line 2474 has 'Bearer{api_key}' but should be 'Bearer {api_key}' (note the space after "Bearer"). The standard Bearer token authentication format requires a space between "Bearer" and the token value. Without this space, authentication will fail when the variable is substituted.

Apply this diff to fix the template:

                                   {
                                     'User-Agent':
                                       'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
-                                    'Authorization': 'Bearer{api_key}',
+                                    'Authorization': 'Bearer {api_key}',
                                   },
web/src/components/auth/RegisterForm.jsx (1)

201-238: Terms consent must be enforced in submission handler

Although the button is disabled, the active handleSubmit implementation never checks agreedToTerms. Users can bypass the checkbox (e.g., by re-enabling the button in devtools or submitting the form programmatically) and register without accepting the agreement/privacy policy. Add a guard in this handler before firing the request so the flow remains blocked without consent.

   async function handleSubmit(e) {
     if (password.length < 8) {
       showInfo('密码长度不得小于 8 位!');
       return;
     }
     if (password !== password2) {
       showInfo('两次输入的密码不一致');
       return;
     }
+    if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
+      showError(t('请先阅读并同意用户协议和隐私政策'));
+      return;
+    }
     if (username && password) {
🧹 Nitpick comments (1)
relay/channel/api_request.go (1)

40-58: Consider documenting supported placeholders.

The processHeaderOverride helper correctly validates header values and replaces the {api_key} placeholder. However, consider documenting the supported placeholders (currently only {api_key}) in the function comment or in a central location to guide future enhancements.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2290acc and f44f682.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • web/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (50)
  • README.en.md (5 hunks)
  • README.fr.md (5 hunks)
  • README.ja.md (1 hunks)
  • README.md (6 hunks)
  • constant/channel.go (2 hunks)
  • controller/channel-test.go (1 hunks)
  • controller/misc.go (1 hunks)
  • controller/task_video.go (2 hunks)
  • docker-compose.yml (3 hunks)
  • go.mod (5 hunks)
  • model/option.go (1 hunks)
  • relay/channel/api_request.go (5 hunks)
  • relay/channel/aws/adaptor.go (1 hunks)
  • relay/channel/claude/adaptor.go (2 hunks)
  • relay/channel/task/doubao/adaptor.go (1 hunks)
  • relay/channel/task/doubao/constants.go (1 hunks)
  • relay/common/relay_info.go (1 hunks)
  • relay/relay_adaptor.go (3 hunks)
  • router/api-router.go (1 hunks)
  • web/jsconfig.json (1 hunks)
  • web/src/App.jsx (2 hunks)
  • web/src/components/auth/RegisterForm.jsx (5 hunks)
  • web/src/components/common/modals/TwoFactorAuthModal.jsx (1 hunks)
  • web/src/components/layout/Footer.jsx (3 hunks)
  • web/src/components/layout/PageLayout.jsx (1 hunks)
  • web/src/components/settings/OtherSetting.jsx (4 hunks)
  • web/src/components/settings/SystemSetting.jsx (9 hunks)
  • web/src/components/table/channels/modals/EditChannelModal.jsx (7 hunks)
  • web/src/components/table/mj-logs/MjLogsFilters.jsx (1 hunks)
  • web/src/components/table/task-logs/TaskLogsColumnDefs.jsx (1 hunks)
  • web/src/components/table/task-logs/TaskLogsFilters.jsx (1 hunks)
  • web/src/components/table/usage-logs/UsageLogsFilters.jsx (1 hunks)
  • web/src/components/topup/RechargeCard.jsx (7 hunks)
  • web/src/components/topup/index.jsx (6 hunks)
  • web/src/components/topup/modals/PaymentConfirmModal.jsx (1 hunks)
  • web/src/constants/channel.constants.js (1 hunks)
  • web/src/constants/console.constants.js (1 hunks)
  • web/src/helpers/api.js (0 hunks)
  • web/src/helpers/render.jsx (2 hunks)
  • web/src/hooks/common/useSidebar.js (1 hunks)
  • web/src/i18n/locales/en.json (4 hunks)
  • web/src/i18n/locales/fr.json (1 hunks)
  • web/src/i18n/locales/zh.json (1 hunks)
  • web/src/pages/PrivacyPolicy/index.jsx (1 hunks)
  • web/src/pages/Setting/Operation/SettingsGeneral.jsx (1 hunks)
  • web/src/pages/Setting/Operation/SettingsMonitoring.jsx (1 hunks)
  • web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx (5 hunks)
  • web/src/pages/Setting/Ratio/ModelRatioSettings.jsx (4 hunks)
  • web/src/pages/UserAgreement/index.jsx (1 hunks)
  • web/src/utils/contentDetector.js (1 hunks)
💤 Files with no reviewable changes (1)
  • web/src/helpers/api.js
✅ Files skipped from review due to trivial changes (14)
  • web/src/components/common/modals/TwoFactorAuthModal.jsx
  • README.ja.md
  • web/src/components/table/usage-logs/UsageLogsFilters.jsx
  • web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx
  • web/src/components/settings/SystemSetting.jsx
  • web/src/constants/console.constants.js
  • web/src/components/table/task-logs/TaskLogsColumnDefs.jsx
  • web/jsconfig.json
  • web/src/components/topup/index.jsx
  • web/src/pages/Setting/Operation/SettingsMonitoring.jsx
  • web/src/components/table/mj-logs/MjLogsFilters.jsx
  • web/src/components/table/task-logs/TaskLogsFilters.jsx
  • web/src/pages/Setting/Operation/SettingsGeneral.jsx
  • web/src/components/topup/RechargeCard.jsx
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-08-27T02:15:25.448Z
Learnt from: AAEE86
PR: QuantumNous/new-api#1658
File: web/src/components/table/channels/modals/EditChannelModal.jsx:555-569
Timestamp: 2025-08-27T02:15:25.448Z
Learning: In EditChannelModal.jsx, the applyModelMapping function transforms the models list by replacing original model names (mapping values) with display names (mapping keys). The database stores this transformed list containing mapped keys. On channel load, data.models contains these mapped display names, making the initialization filter if (data.models.includes(key)) correct.

Applied to files:

  • web/src/components/table/channels/modals/EditChannelModal.jsx
📚 Learning: 2025-08-27T02:15:25.448Z
Learnt from: AAEE86
PR: QuantumNous/new-api#1658
File: web/src/components/table/channels/modals/EditChannelModal.jsx:555-569
Timestamp: 2025-08-27T02:15:25.448Z
Learning: In EditChannelModal.jsx, the database stores mapped keys (display names) in the models field after applying model mapping transformations. When loading a channel, data.models contains the mapped keys, not the original model names. The filtering logic if (data.models.includes(key)) in the initialization is correct.

Applied to files:

  • web/src/components/table/channels/modals/EditChannelModal.jsx
🧬 Code graph analysis (20)
controller/channel-test.go (1)
constant/channel.go (1)
  • ChannelTypeDoubaoVideo (54-54)
relay/channel/claude/adaptor.go (1)
relay/common/relay_info.go (1)
  • RelayInfo (74-121)
controller/misc.go (1)
common/constants.go (2)
  • OptionMapRWMutex (37-37)
  • OptionMap (36-36)
router/api-router.go (1)
controller/misc.go (2)
  • GetUserAgreement (150-159)
  • GetPrivacyPolicy (161-170)
web/src/components/layout/PageLayout.jsx (1)
web/src/components/layout/SiderBar.jsx (1)
  • location (66-66)
web/src/hooks/common/useSidebar.js (1)
web/src/components/dashboard/index.jsx (1)
  • handleRefresh (98-103)
model/option.go (1)
common/constants.go (1)
  • OptionMap (36-36)
web/src/pages/PrivacyPolicy/index.jsx (5)
web/src/App.jsx (1)
  • PrivacyPolicy (55-55)
web/src/pages/UserAgreement/index.jsx (7)
  • useTranslation (32-32)
  • contentType (35-35)
  • htmlBody (36-36)
  • htmlStyles (37-37)
  • htmlLinks (38-38)
  • HEADER_HEIGHT (40-40)
  • renderContent (175-247)
web/src/utils/contentDetector.js (2)
  • getContentType (50-61)
  • getContentType (50-61)
web/src/helpers/utils.jsx (1)
  • showError (122-151)
web/src/components/common/markdown/MarkdownRenderer.jsx (1)
  • MarkdownRenderer (594-652)
web/src/App.jsx (3)
web/src/pages/UserAgreement/index.jsx (1)
  • UserAgreement (31-250)
web/src/pages/PrivacyPolicy/index.jsx (1)
  • PrivacyPolicy (31-247)
web/src/components/common/ui/Loading.jsx (1)
  • Loading (23-29)
web/src/components/table/channels/modals/EditChannelModal.jsx (2)
web/src/components/settings/PersonalSetting.jsx (1)
  • handleInputChange (168-170)
web/src/components/table/channels/modals/EditTagModal.jsx (1)
  • handleInputChange (77-132)
web/src/components/settings/OtherSetting.jsx (1)
web/src/helpers/utils.jsx (2)
  • showSuccess (157-159)
  • showError (122-151)
web/src/pages/Setting/Ratio/ModelRatioSettings.jsx (1)
web/src/components/settings/RatioSetting.jsx (1)
  • inputs (35-49)
relay/channel/aws/adaptor.go (1)
relay/channel/claude/adaptor.go (1)
  • CommonClaudeHeadersOperation (67-74)
relay/channel/task/doubao/adaptor.go (7)
relay/common/relay_info.go (3)
  • RelayInfo (74-121)
  • TaskSubmitReq (483-492)
  • TaskInfo (502-511)
relay/common/relay_utils.go (2)
  • ValidateBasicTaskRequest (67-96)
  • HasImage (18-20)
relay/channel/api_request.go (1)
  • DoTaskApiRequest (300-322)
service/error.go (1)
  • TaskErrorWrapper (139-155)
service/http_client.go (1)
  • GetHttpClient (32-34)
relay/channel/task/doubao/constants.go (2)
  • ModelList (3-7)
  • ChannelName (9-9)
model/task.go (4)
  • TaskStatusQueued (16-16)
  • TaskStatusInProgress (17-17)
  • TaskStatusSuccess (19-19)
  • TaskStatusFailure (18-18)
relay/channel/api_request.go (1)
types/error.go (2)
  • NewError (193-213)
  • ErrorCodeChannelHeaderOverrideInvalid (53-53)
web/src/utils/contentDetector.js (1)
web/src/components/table/users/UsersColumnDefs.jsx (1)
  • content (115-119)
web/src/pages/UserAgreement/index.jsx (4)
web/src/pages/PrivacyPolicy/index.jsx (4)
  • contentType (35-35)
  • htmlBody (36-36)
  • HEADER_HEIGHT (40-40)
  • renderContent (172-244)
web/src/utils/contentDetector.js (2)
  • getContentType (50-61)
  • getContentType (50-61)
web/src/helpers/utils.jsx (1)
  • showError (122-151)
web/src/components/common/markdown/MarkdownRenderer.jsx (1)
  • MarkdownRenderer (594-652)
controller/task_video.go (6)
setting/ratio_setting/model_ratio.go (1)
  • GetModelRatio (430-441)
model/user.go (4)
  • GetUserById (287-299)
  • DecreaseUserQuota (777-792)
  • UpdateUserUsedQuotaAndRequestCount (823-830)
  • IncreaseUserQuota (752-767)
setting/ratio_setting/group_ratio.go (2)
  • GetGroupRatio (64-74)
  • GetGroupGroupRatio (76-89)
logger/logger.go (3)
  • LogInfo (55-57)
  • LogQuota (94-100)
  • LogError (63-65)
model/channel.go (1)
  • UpdateChannelUsedQuota (740-746)
model/log.go (1)
  • RecordLog (78-94)
web/src/helpers/render.jsx (1)
web/src/hooks/model-pricing/useModelPricingData.jsx (1)
  • groupRatio (50-50)
relay/relay_adaptor.go (3)
constant/channel.go (1)
  • ChannelTypeDoubaoVideo (54-54)
relay/channel/task/doubao/adaptor.go (1)
  • TaskAdaptor (67-71)
relay/channel/adapter.go (1)
  • TaskAdaptor (32-51)
🪛 Biome (2.1.2)
web/src/pages/PrivacyPolicy/index.jsx

[error] 231-231: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)

web/src/pages/UserAgreement/index.jsx

[error] 231-233: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)

web/src/components/layout/Footer.jsx

[error] 229-229: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)

🔇 Additional comments (24)
web/src/components/table/channels/modals/EditChannelModal.jsx (3)

94-95: LGTM!

The consolidation of MODEL_FETCHABLE_TYPES to a single-line format is a cosmetic change that improves readability while preserving all original values.


267-272: LGTM!

The scrollIntoView options are correctly formatted with standard properties (behavior, block, inline). The inline style improves code consistency.


328-354: LGTM!

The inline styles for the navigation buttons correctly implement circular icon button styling with proper flexbox centering. The properties are valid and appropriate.

controller/channel-test.go (1)

73-78: LGTM!

The early-return branch for the Doubao Video channel follows the established pattern for unsupported channel types. The placement is consistent and the error message is clear.

router/api-router.go (1)

23-24: Verify rate limiting for public endpoints.

The new endpoints for user agreement and privacy policy follow the established pattern. However, ensure that public access without rate limiting is acceptable for these endpoints, especially if the content could be large.

relay/relay_adaptor.go (3)

107-108: LGTM!

The addition of the Submodel adaptor follows the established pattern and correctly returns a submodel.Adaptor instance.


138-139: LGTM!

The Doubao video task adaptor is correctly wired to the ChannelTypeDoubaoVideo constant (54) and follows the established pattern for task adaptors.


4-4: Keep gin import - gin.Context is used in GetTaskPlatform signature (relay/relay_adaptor.go:113).

Likely an incorrect or invalid review comment.

constant/channel.go (2)

54-54: LGTM!

The ChannelTypeDoubaoVideo constant follows the sequential numbering pattern and is correctly integrated throughout the codebase.


114-114: LGTM!

The base URL for the Doubao Video channel matches the Volcengine base URL and is correctly placed at index 54.

relay/common/relay_info.go (1)

503-510: LGTM!

The new token fields in TaskInfo enable per-billing token tracking without breaking changes. The fields are correctly marked as optional with omitempty tags.

relay/channel/task/doubao/constants.go (1)

3-9: LGTM!

The model list and channel name are clearly defined for the Doubao video channel. The model identifiers follow a consistent naming pattern.

relay/channel/api_request.go (3)

73-79: LGTM!

The integration of processHeaderOverride in DoApiRequest follows the DRY principle and correctly handles errors.


106-112: LGTM!

The integration of processHeaderOverride in DoFormRequest is consistent with the pattern used in DoApiRequest.


130-136: LGTM!

The integration of processHeaderOverride in DoWssRequest maintains consistency across all request functions.

controller/misc.go (1)

150-170: LGTM!

The GetUserAgreement and GetPrivacyPolicy handlers follow the established pattern used by GetAbout and GetNotice. The read lock usage is correct and consistent.

relay/channel/claude/adaptor.go (1)

67-74: Good consolidation of Claude header handling

Centralizing anthropic-beta passthrough and settings-based header writes improves maintainability. No issues spotted.

Also applies to: 84-84

web/src/pages/Setting/Ratio/ModelRatioSettings.jsx (1)

229-246: i18n text and handlers look good

Wrapping strings with t() and simplifying onChange are fine; no logic changes.

Also applies to: 254-256, 267-267, 275-281

README.md (1)

78-78: Docs updates read well

Wording and links align with added features; looks consistent.

Also applies to: 122-125, 138-141, 185-185, 199-201

model/option.go (1)

64-65: New option defaults added correctly

UserAgreement/PrivacyPolicy keys initialized; aligns with new endpoints/UI.

web/src/i18n/locales/fr.json (1)

2176-2257: FR locale additions look consistent

New UserAgreement/PrivacyPolicy/Passkey strings are coherent; no JSON issues spotted.

web/src/helpers/render.jsx (1)

340-341: Channel icon for Doubao Video

Case 54 mapping added; icon choice is consistent with 45 (Doubao).

web/src/App.jsx (1)

54-56: New routes wired correctly

Lazy imports and Suspense usage for /user-agreement and /privacy-policy follow existing patterns.

Also applies to: 306-321

web/src/constants/channel.constants.js (1)

168-171: Channel option added

Entry for value 54 (豆包视频) looks correct and consistent with icon mapping.

Comment thread controller/task_video.go
Comment on lines +137 to +147
user, err := model.GetUserById(task.UserId, false)
if err == nil {
groupRatio := ratio_setting.GetGroupRatio(user.Group)
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(user.Group, user.Group)

var finalGroupRatio float64
if hasUserGroupRatio {
finalGroupRatio = userGroupRatio
} else {
finalGroupRatio = groupRatio
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Incorrect group ratio lookup breaks cross-group billing

GetGroupGroupRatio expects the user's group and the channel's group. Passing user.Group twice means any configured cross-group ratio is ignored, so the final quota never reflects channel-specific pricing. This under/over-charges whenever channel groups carry custom ratios. Please pass the channel's group when computing the final ratio.

-							groupRatio := ratio_setting.GetGroupRatio(user.Group)
-							userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(user.Group, user.Group)
+							groupRatio := ratio_setting.GetGroupRatio(user.Group)
+							userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(user.Group, channel.Group)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
user, err := model.GetUserById(task.UserId, false)
if err == nil {
groupRatio := ratio_setting.GetGroupRatio(user.Group)
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(user.Group, user.Group)
var finalGroupRatio float64
if hasUserGroupRatio {
finalGroupRatio = userGroupRatio
} else {
finalGroupRatio = groupRatio
}
user, err := model.GetUserById(task.UserId, false)
if err == nil {
groupRatio := ratio_setting.GetGroupRatio(user.Group)
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(user.Group, channel.Group)
var finalGroupRatio float64
if hasUserGroupRatio {
finalGroupRatio = userGroupRatio
} else {
finalGroupRatio = groupRatio
}
🤖 Prompt for AI Agents
In controller/task_video.go around lines 137 to 147, the call to
GetGroupGroupRatio mistakenly passes user.Group for both parameters which
prevents cross-group ratios from being applied; change the second argument to
the channel's group (e.g., channel.Group) so the function is invoked as
GetGroupGroupRatio(user.Group, channel.Group), then use that returned ratio when
hasUserGroupRatio is true so channel-specific pricing is honored.

Comment thread docker-compose.yml
# - mysql # Uncomment if using MySQL
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk -F: '{print $$2}'"]
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Healthcheck fails when JSON contains spaces
grep without -P/-E treats \s literally, so the pattern only matches when there is no space between : and true. If the API returns \"success\": true, the healthcheck exits 1 and keeps the container unhealthy. Please adjust the regex to tolerate whitespace, e.g.:

-      test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]
+      test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -q '\"success\":[[:space:]]*true' || exit 1"]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -q '\"success\":[[:space:]]*true' || exit 1"]
🤖 Prompt for AI Agents
In docker-compose.yml at line 44, the healthcheck grep pattern treats \s
literally so it fails when JSON contains spaces; update the test command to use
a regex that tolerates optional whitespace (for example use grep -E with a
pattern like '"success":[[:space:]]*true' or grep -P with '\s*') so the
healthcheck matches both "\"success\":true" and "\"success\": true" and returns
success accordingly.

Comment thread go.mod
go 1.24.0

toolchain go1.24.6
go 1.25.1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Unsupported Go toolchain version.

go 1.25.1 will fail today—the Go team hasn’t shipped 1.25 (nor 1.25.1) yet, so go refuses to build this module. Please stick with the latest released toolchain (e.g., 1.23.x or whatever you’ve validated) until 1.25 officially lands.

🤖 Prompt for AI Agents
In go.mod around line 4, the declared Go toolchain version "go 1.25.1" is not
released and will cause builds to fail; change it to a released, supported
version you’ve validated (for example "go 1.23" or "go 1.24" if tested) by
updating the go directive to that released version and re-run go mod tidy/build
to ensure module compatibility and update any go-version-dependent code if
needed.

Comment on lines +124 to +130
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
return
}
_ = resp.Body.Close()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Ensure response body closes on every exit path.

If io.ReadAll errors, the current code returns without closing resp.Body, leaking the connection. Add a defer resp.Body.Close() before reading so the body is closed even when the read fails.

Here is an example fix:

-	responseBody, err := io.ReadAll(resp.Body)
+	defer resp.Body.Close()
+
+	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
 		taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
 		return
 	}
-	_ = resp.Body.Close()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
return
}
_ = resp.Body.Close()
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
return
}
🤖 Prompt for AI Agents
In relay/channel/task/doubao/adaptor.go around lines 124 to 130, the response
body is closed only after a successful ReadAll which leaks the connection when
ReadAll returns an error; add defer resp.Body.Close() immediately after
confirming resp.Body is non-nil (before calling io.ReadAll) so the body is
closed on every exit path, and remove or stop using the later explicit _ =
resp.Body.Close() to avoid double-close.

Comment on lines +132 to +144
var dResp responsePayload
if err := json.Unmarshal(responseBody, &dResp); err != nil {
taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
return
}

if dResp.ID == "" {
taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, gin.H{"task_id": dResp.ID})
return dResp.ID, responseBody, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Propagate upstream error responses instead of masking them.

When Doubao returns a non-2xx status, we still attempt to unmarshal an id, end up with an empty ID, and report a 500 “invalid_response”. This hides the real upstream failure code/message from the caller. Check resp.StatusCode right after reading the body and surface a TaskError that carries the upstream status (and, ideally, the upstream error payload) so clients receive accurate feedback.

A possible adjustment:

 	if err := json.Unmarshal(responseBody, &dResp); err != nil {
 		taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
 		return
 	}
+
+	if resp.StatusCode >= http.StatusBadRequest {
+		taskErr = service.TaskErrorWrapper(
+			fmt.Errorf("upstream returned status %d: %s", resp.StatusCode, responseBody),
+			"upstream_request_failed",
+			resp.StatusCode,
+		)
+		return
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var dResp responsePayload
if err := json.Unmarshal(responseBody, &dResp); err != nil {
taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
return
}
if dResp.ID == "" {
taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{"task_id": dResp.ID})
return dResp.ID, responseBody, nil
var dResp responsePayload
if err := json.Unmarshal(responseBody, &dResp); err != nil {
taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
return
}
if resp.StatusCode >= http.StatusBadRequest {
taskErr = service.TaskErrorWrapper(
fmt.Errorf("upstream returned status %d: %s", resp.StatusCode, responseBody),
"upstream_request_failed",
resp.StatusCode,
)
return
}
if dResp.ID == "" {
taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{"task_id": dResp.ID})
return dResp.ID, responseBody, nil
🤖 Prompt for AI Agents
In relay/channel/task/doubao/adaptor.go around lines 132 to 144, the code
unmarshals the response and returns a 500 when the ID is empty, which masks
upstream non-2xx errors; after reading responseBody check resp.StatusCode and if
it's not 2xx wrap and return a TaskError that includes the upstream status code
and the raw response body (or payload) instead of attempting to unmarshal an ID,
only unmarshal when status is 2xx and then validate ID as now; this surfaces the
real upstream error code/message to callers.

Comment on lines +51 to 64
const cardProPages = [
'/console/channel',
'/console/log',
'/console/redemption',
'/console/user',
'/console/token',
'/console/midjourney',
'/console/task',
'/console/models',
'/pricing',
];

const shouldHideFooter = cardProPages.includes(location.pathname);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Footer now visible on console subroutes

Switching to exact-match checks means routes like /console/channel/123 or other nested console pages will no longer hide the footer, unlike the previous prefix-based logic. Reintroduce a prefix/regex check (or expand the list to cover subpaths) so every console view that should suppress the footer still does.

(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
(imageGenerationCallPrice * groupRatio);
imageGenerationCallPrice * groupRatio;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Bug: image generation fee always added to total

You always add imageGenerationCallPrice * groupRatio to price, even when imageGenerationCall is false. This inflates totals when no image generation occurs.

Fix by guarding with the flag:

-      (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
-      imageGenerationCallPrice * groupRatio;
+      (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
+      (imageGenerationCall ? imageGenerationCallPrice * groupRatio : 0);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
imageGenerationCallPrice * groupRatio;
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
(imageGenerationCall ? imageGenerationCallPrice * groupRatio : 0);
🤖 Prompt for AI Agents
In web/src/helpers/render.jsx around line 1077, the expression
imageGenerationCallPrice * groupRatio is always added to the total price even
when imageGenerationCall is false; wrap that addition in a conditional that
checks imageGenerationCall (or the appropriate boolean flag in scope) and only
add imageGenerationCallPrice * groupRatio when the flag is true, keeping all
other pricing logic unchanged.

Comment on lines +125 to +170
// inject inline styles for parsed HTML content and cleanup on unmount or styles change
useEffect(() => {
const styleId = 'privacy-policy-inline-styles';
const createdLinkIds = [];

if (htmlStyles) {
let styleEl = document.getElementById(styleId);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = styleId;
styleEl.type = 'text/css';
document.head.appendChild(styleEl);
}
styleEl.innerHTML = htmlStyles;
} else {
const el = document.getElementById(styleId);
if (el) el.remove();
}

if (htmlLinks && htmlLinks.length) {
htmlLinks.forEach((href, idx) => {
try {
const existing = document.querySelector(`link[rel="stylesheet"][href="${href}"]`);
if (existing) return;
const linkId = `${styleId}-link-${idx}`;
const linkEl = document.createElement('link');
linkEl.id = linkId;
linkEl.rel = 'stylesheet';
linkEl.href = href;
document.head.appendChild(linkEl);
createdLinkIds.push(linkId);
} catch (e) {
// ignore
}
});
}

return () => {
const el = document.getElementById(styleId);
if (el) el.remove();
createdLinkIds.forEach((id) => {
const l = document.getElementById(id);
if (l) l.remove();
});
};
}, [htmlStyles]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing dependency prevents stylesheet injection

This effect reads htmlLinks, but the dependency list only includes htmlStyles. When cached HTML comes with external stylesheets (or when the inline styles stay the same), the effect never reruns, so required <link> tags are never added to <head>. The rendered policy loses its styles. Please include htmlLinks (and, if desired, memoize cleanup accordingly) so we always sync injected assets.

-  }, [htmlStyles]);
+  }, [htmlStyles, htmlLinks]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// inject inline styles for parsed HTML content and cleanup on unmount or styles change
useEffect(() => {
const styleId = 'privacy-policy-inline-styles';
const createdLinkIds = [];
if (htmlStyles) {
let styleEl = document.getElementById(styleId);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = styleId;
styleEl.type = 'text/css';
document.head.appendChild(styleEl);
}
styleEl.innerHTML = htmlStyles;
} else {
const el = document.getElementById(styleId);
if (el) el.remove();
}
if (htmlLinks && htmlLinks.length) {
htmlLinks.forEach((href, idx) => {
try {
const existing = document.querySelector(`link[rel="stylesheet"][href="${href}"]`);
if (existing) return;
const linkId = `${styleId}-link-${idx}`;
const linkEl = document.createElement('link');
linkEl.id = linkId;
linkEl.rel = 'stylesheet';
linkEl.href = href;
document.head.appendChild(linkEl);
createdLinkIds.push(linkId);
} catch (e) {
// ignore
}
});
}
return () => {
const el = document.getElementById(styleId);
if (el) el.remove();
createdLinkIds.forEach((id) => {
const l = document.getElementById(id);
if (l) l.remove();
});
};
}, [htmlStyles]);
// inject inline styles for parsed HTML content and cleanup on unmount or styles change
useEffect(() => {
const styleId = 'privacy-policy-inline-styles';
const createdLinkIds = [];
if (htmlStyles) {
let styleEl = document.getElementById(styleId);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = styleId;
styleEl.type = 'text/css';
document.head.appendChild(styleEl);
}
styleEl.innerHTML = htmlStyles;
} else {
const el = document.getElementById(styleId);
if (el) el.remove();
}
if (htmlLinks && htmlLinks.length) {
htmlLinks.forEach((href, idx) => {
try {
const existing = document.querySelector(`link[rel="stylesheet"][href="${href}"]`);
if (existing) return;
const linkId = `${styleId}-link-${idx}`;
const linkEl = document.createElement('link');
linkEl.id = linkId;
linkEl.rel = 'stylesheet';
linkEl.href = href;
document.head.appendChild(linkEl);
createdLinkIds.push(linkId);
} catch (e) {
// ignore
}
});
}
return () => {
const el = document.getElementById(styleId);
if (el) el.remove();
createdLinkIds.forEach((id) => {
const l = document.getElementById(id);
if (l) l.remove();
});
};
}, [htmlStyles, htmlLinks]);
🤖 Prompt for AI Agents
In web/src/pages/PrivacyPolicy/index.jsx around lines 125 to 170, the useEffect
reads htmlLinks but only lists htmlStyles in its dependency array which prevents
link tags from being injected when htmlLinks changes; update the effect to
include htmlLinks in the dependency array (and if htmlLinks is an array, ensure
a stable reference or memoize it e.g. via useMemo or by joining values) so the
effect reruns to inject/remove link elements, and keep the existing cleanup
logic to remove the style and any created link IDs on unmount or when
dependencies change.

Comment on lines +42 to +116
const displayUserAgreement = async () => {
// 先从缓存中获取
const cachedContent = localStorage.getItem('user_agreement') || '';
if (cachedContent) {
setUserAgreement(cachedContent);
const ct = getContentType(cachedContent);
setContentType(ct);
if (ct === 'html') {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(cachedContent, 'text/html');
setHtmlBody(doc.body ? doc.body.innerHTML : cachedContent);
const styles = Array.from(doc.querySelectorAll('style'))
.map((s) => s.innerHTML)
.join('\n');
setHtmlStyles(styles);
const links = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'))
.map((l) => l.getAttribute('href') || l.href)
.filter(Boolean);
setHtmlLinks(links);
} catch (e) {
setHtmlBody(cachedContent);
setHtmlStyles('');
setHtmlLinks([]);
}
}
}

try {
const res = await API.get('/api/user-agreement');
const { success, message, data } = res.data;
if (success && data) {
// 直接使用原始数据,不进行任何预处理
setUserAgreement(data);
const ct = getContentType(data);
setContentType(ct);
if (ct === 'html') {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(data, 'text/html');
setHtmlBody(doc.body ? doc.body.innerHTML : data);
const styles = Array.from(doc.querySelectorAll('style'))
.map((s) => s.innerHTML)
.join('\n');
setHtmlStyles(styles);
const links = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'))
.map((l) => l.getAttribute('href') || l.href)
.filter(Boolean);
setHtmlLinks(links);
} catch (e) {
setHtmlBody(data);
setHtmlStyles('');
setHtmlLinks([]);
}
} else {
setHtmlBody('');
setHtmlStyles('');
setHtmlLinks([]);
}
localStorage.setItem('user_agreement', data);
} else {
if (!cachedContent) {
showError(message || t('加载用户协议内容失败...'));
setUserAgreement('');
setContentType('empty');
}
}
} catch (error) {
if (!cachedContent) {
showError(t('加载用户协议内容失败...'));
setUserAgreement('');
setContentType('empty');
}
}
setUserAgreementLoaded(true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Sanitize policy content before injecting into the DOM

displayUserAgreement stores whatever the backend returns and later feeds it straight into dangerouslySetInnerHTML (Line 226) and even hoists <style>/<link> nodes into <head>. Without sanitization this is a stored-XSS sink—any HTML entered through the admin panel (or intercepted in transit) can embed <script>, event handlers, or javascript: URLs that will execute for every visitor. Please sanitize both the cached content and the freshly fetched response (e.g., via DOMPurify) before setting state, persisting to localStorage, or parsing for styles/links.

+import DOMPurify from 'dompurify';
...
-    const cachedContent = localStorage.getItem('user_agreement') || '';
-    if (cachedContent) {
-      setUserAgreement(cachedContent);
-      const ct = getContentType(cachedContent);
+    const cachedRaw = localStorage.getItem('user_agreement') || '';
+    const cachedContent = cachedRaw ? DOMPurify.sanitize(cachedRaw, { USE_PROFILES: { html: true } }) : '';
+    if (cachedContent) {
+      setUserAgreement(cachedContent);
+      const ct = getContentType(cachedContent);
       ...
     }
...
-      const { success, message, data } = res.data;
+      const { success, message, data } = res.data;
       if (success && data) {
-        setUserAgreement(data);
-        const ct = getContentType(data);
+        const sanitized = DOMPurify.sanitize(data, { USE_PROFILES: { html: true } });
+        setUserAgreement(sanitized);
+        const ct = getContentType(sanitized);
         setContentType(ct);
         if (ct === 'html') {
           try {
             const parser = new DOMParser();
-            const doc = parser.parseFromString(data, 'text/html');
-            setHtmlBody(doc.body ? doc.body.innerHTML : data);
+            const doc = parser.parseFromString(sanitized, 'text/html');
+            setHtmlBody(doc.body ? DOMPurify.sanitize(doc.body.innerHTML, { USE_PROFILES: { html: true } }) : sanitized);
             ...
           } catch (e) {
-            setHtmlBody(data);
+            setHtmlBody(sanitized);
             setHtmlStyles('');
             setHtmlLinks([]);
           }
         } else {
           setHtmlBody('');
           setHtmlStyles('');
           setHtmlLinks([]);
         }
-        localStorage.setItem('user_agreement', data);
+        localStorage.setItem('user_agreement', sanitized);
       } else {

Also applies to: 215-227

🤖 Prompt for AI Agents
In web/src/pages/UserAgreement/index.jsx around lines 42 to 116, the function
displayUserAgreement currently takes cached and fetched HTML and sets it
directly into state and localStorage and parses it for styles/links — this is a
stored-XSS sink; sanitize both the cachedContent and the fetched data before any
setUserAgreement, localStorage.setItem, or parsing. Use a trusted sanitizer
(e.g., DOMPurify) to sanitize the raw string first, then parse the sanitized
string with DOMParser to extract safe body HTML, safe <style> contents, and
trusted stylesheet hrefs (validate/whitelist href protocols and strip
javascript: or data: URLs). For non-html types, leave behavior unchanged; ensure
any fallback/error paths also use sanitized values and do not persist
unsanitized content.

Comment on lines +123 to +174
// inject inline styles for parsed HTML content and cleanup on unmount or styles change
useEffect(() => {
// if there's nothing to inject, remove any existing injected elements
const styleId = 'user-agreement-inline-styles';
const createdLinkIds = [];

// handle style tags
if (htmlStyles) {
let styleEl = document.getElementById(styleId);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = styleId;
styleEl.type = 'text/css';
document.head.appendChild(styleEl);
}
styleEl.innerHTML = htmlStyles;
} else {
const el = document.getElementById(styleId);
if (el) el.remove();
}

// handle external stylesheet links
if (htmlLinks && htmlLinks.length) {
htmlLinks.forEach((href, idx) => {
try {
// avoid duplicate injection if a link with same href already exists
const existing = document.querySelector(`link[rel="stylesheet"][href="${href}"]`);
if (existing) return;
const linkId = `${styleId}-link-${idx}`;
const linkEl = document.createElement('link');
linkEl.id = linkId;
linkEl.rel = 'stylesheet';
linkEl.href = href;
document.head.appendChild(linkEl);
createdLinkIds.push(linkId);
} catch (e) {
// ignore malformed hrefs
}
});
}

return () => {
const el = document.getElementById(styleId);
if (el) el.remove();
// remove only the links we created
createdLinkIds.forEach((id) => {
const l = document.getElementById(id);
if (l) l.remove();
});
};
}, [htmlStyles]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Include htmlLinks in the effect dependencies

The stylesheet injection effect reads htmlLinks, but it only depends on htmlStyles. If the backend returns new HTML that changes <link>s while the inline styles remain the same (very common when only external CSS references differ), the effect never re-runs, so new stylesheets are skipped and previously injected ones linger. Please add htmlLinks to the dependency list so React re-injects/cleans up when the link set changes.

-  }, [htmlStyles]);
+  }, [htmlStyles, htmlLinks]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// inject inline styles for parsed HTML content and cleanup on unmount or styles change
useEffect(() => {
// if there's nothing to inject, remove any existing injected elements
const styleId = 'user-agreement-inline-styles';
const createdLinkIds = [];
// handle style tags
if (htmlStyles) {
let styleEl = document.getElementById(styleId);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = styleId;
styleEl.type = 'text/css';
document.head.appendChild(styleEl);
}
styleEl.innerHTML = htmlStyles;
} else {
const el = document.getElementById(styleId);
if (el) el.remove();
}
// handle external stylesheet links
if (htmlLinks && htmlLinks.length) {
htmlLinks.forEach((href, idx) => {
try {
// avoid duplicate injection if a link with same href already exists
const existing = document.querySelector(`link[rel="stylesheet"][href="${href}"]`);
if (existing) return;
const linkId = `${styleId}-link-${idx}`;
const linkEl = document.createElement('link');
linkEl.id = linkId;
linkEl.rel = 'stylesheet';
linkEl.href = href;
document.head.appendChild(linkEl);
createdLinkIds.push(linkId);
} catch (e) {
// ignore malformed hrefs
}
});
}
return () => {
const el = document.getElementById(styleId);
if (el) el.remove();
// remove only the links we created
createdLinkIds.forEach((id) => {
const l = document.getElementById(id);
if (l) l.remove();
});
};
}, [htmlStyles]);
// inject inline styles for parsed HTML content and cleanup on unmount or styles change
useEffect(() => {
// if there's nothing to inject, remove any existing injected elements
const styleId = 'user-agreement-inline-styles';
const createdLinkIds = [];
// handle style tags
if (htmlStyles) {
let styleEl = document.getElementById(styleId);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = styleId;
styleEl.type = 'text/css';
document.head.appendChild(styleEl);
}
styleEl.innerHTML = htmlStyles;
} else {
const el = document.getElementById(styleId);
if (el) el.remove();
}
// handle external stylesheet links
if (htmlLinks && htmlLinks.length) {
htmlLinks.forEach((href, idx) => {
try {
// avoid duplicate injection if a link with same href already exists
const existing = document.querySelector(`link[rel="stylesheet"][href="${href}"]`);
if (existing) return;
const linkId = `${styleId}-link-${idx}`;
const linkEl = document.createElement('link');
linkEl.id = linkId;
linkEl.rel = 'stylesheet';
linkEl.href = href;
document.head.appendChild(linkEl);
createdLinkIds.push(linkId);
} catch (e) {
// ignore malformed hrefs
}
});
}
return () => {
const el = document.getElementById(styleId);
if (el) el.remove();
// remove only the links we created
createdLinkIds.forEach((id) => {
const l = document.getElementById(id);
if (l) l.remove();
});
};
}, [htmlStyles, htmlLinks]);
🤖 Prompt for AI Agents
In web/src/pages/UserAgreement/index.jsx around lines 123 to 174, the effect
injects external stylesheet links but only lists htmlStyles in its dependency
array; add htmlLinks to the dependencies (e.g., change [htmlStyles] to
[htmlStyles, htmlLinks]) so the effect re-runs and cleans up/re-injects when the
set of external links changes.

@Calcium-Ion
Calcium-Ion merged commit 5d33ec8 into QuantumNous:alpha Oct 3, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants