Skip to content

feat(wallet): add affiliate withdrawal feature - #5856

Open
Digital631 wants to merge 1 commit into
QuantumNous:mainfrom
Digital631:feat/wallet-withdrawal
Open

feat(wallet): add affiliate withdrawal feature#5856
Digital631 wants to merge 1 commit into
QuantumNous:mainfrom
Digital631:feat/wallet-withdrawal

Conversation

@Digital631

@Digital631 Digital631 commented Jul 2, 2026

Copy link
Copy Markdown

Description

This PR adds a configurable affiliate rebate module for referral rewards.

It introduces backend support for affiliate reward rules, withdrawal requests, and admin-controlled settings. Referral rewards can now be enabled globally, configured by percentage, optionally delayed until the invited user consumes the credited quota, and overridden per user. Redemption-code top-ups can also be included in affiliate rebates through a separate system setting.

On the frontend, the wallet referral card now shows available, pending, total earned rewards, and invite count, with withdrawal support when enabled. The affiliate withdrawal dialog and API integration are added, and all related UI labels are localized across the existing locale files.

Type of change

  • 🐛 Bug fix
  • ✨ New feature
  • ⚡ Refactor
  • 📝 Documentation

Related Issue

  • Closes # (if applicable)

Checklist

  • I have reviewed and written this description manually.
  • I have searched existing Issues and PRs to confirm this is not a duplicate.
  • If this PR is marked as a bug fix, I have linked the related Issue.
  • I understand how these changes work and their potential impact.
  • This PR is focused on the affiliate/referral reward feature.
  • I have completed local testing or manual verification.
  • No sensitive credentials are included.

Proof of Work

  • Added affiliate withdrawal model, API endpoints, routes, and wallet UI integration.
  • Added system settings for affiliate rebate rules, withdrawal availability, settlement behavior, and redemption-code rebate eligibility.
  • Added i18n coverage for affiliate-related UI text across the current locale files.
  • Applied Go formatting to touched backend files.
  • Full runtime testing/manual verification to be completed before merge.

Summary by CodeRabbit

  • New Features

    • Added affiliate rewards and withdrawal support, including new withdrawal actions, status tracking, and admin processing.
    • Users can now configure custom referral rebate rules, reward percentages, and settlement timing.
    • Wallet and settings screens now surface affiliate balances, pending rewards, and related controls.
  • Bug Fixes

    • Affiliate rewards now follow updated settlement behavior and can be released after invitee activity.
    • Top-ups and redemptions now correctly trigger affiliate reward handling during processing.

- add withdrawal UI in affiliate-rewards-card
- add AffiliateWithdrawal API endpoints and routes
- add withdrawal dialog integration
- add AffiliateWithdrawal model and registration
- add system settings for affiliate module
- add i18n support (en, fr, ja)
- update Go module dependencies (go.mod / go.sum)
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR introduces an affiliate rebate and withdrawal system: new AffiliateUserRule, AffiliateRebate, and AffiliateWithdrawal models with rebate creation/release logic triggered by top-ups and redemptions, admin/global affiliate settings, per-user rule overrides, withdrawal API endpoints, and corresponding frontend settings, wallet UI, and translations across six locales.

Changes

Affiliate Rebate & Withdrawal Feature

Layer / File(s) Summary
Affiliate data model, persistence, and migrations
model/affiliate.go, model/main.go, model/task_cas_test.go, model/affiliate_test.go
Adds AffiliateUserRule/AffiliateRebate/AffiliateWithdrawal structs with CRUD, rebate creation/release, withdrawal creation/status update, migration wiring, and tests.
Top-up and redemption rebate triggers
model/topup.go, model/redemption.go, controller/topup.go
Wires rebate creation into Stripe/Creem/Waffo/manual top-up transactions, Epay notify, redemption, and exposes affiliate flags in top-up info responses.
User invite reward and quota release logic
model/user.go
Refactors invite reward flow with explicit reward quota parameter and releases pending rebates when quota/used quota change.
Admin affiliate settings backend
controller/option.go, setting/operation_setting/affiliate_setting.go
Adds AffiliateSetting config and payment-compliance validation for affiliate option updates.
User affiliate rule and withdrawal API
controller/user.go, router/api-router.go
Adds affiliate rule payload helpers, withdrawal CRUD handlers, updated GetUser/GetSelf/UpdateUser, and new withdrawal routes.
Billing settings admin UI
web/default/src/features/system-settings/billing/*, web/default/src/features/system-settings/general/quota-settings-section.tsx, web/default/src/features/system-settings/types.ts
Extends billing settings defaults, wiring, schema, and UI controls for affiliate configuration.
Per-user affiliate rule UI
web/default/src/features/users/components/users-mutate-drawer.tsx, web/default/src/features/users/lib/user-form.ts, web/default/src/features/users/types.ts
Adds Referral Rebate Rule UI section and extends user form schema/types.
Wallet affiliate withdrawal UI
web/default/src/features/wallet/*
Adds withdrawal API, dialog, hook support, and wiring into wallet page and rewards card.
Referral rebate and withdrawal translations
web/default/src/i18n/locales/*.json
Adds/updates referral rebate and withdrawal translation strings across six locales.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: Calcium-Ion, creamlike1024, seefs001

Poem

A rabbit hops with referral cheer,
Rebates now settle, quota drawn near,
Withdrawals queued, compliance confirmed,
Six tongues now speak what the ledger's learned,
Hop, hop, hooray — the affiliate's here! 🐇💰

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches a real part of the changeset by highlighting the new affiliate withdrawal feature in the wallet.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@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: 10

🧹 Nitpick comments (3)
web/default/src/features/wallet/components/dialogs/affiliate-withdrawal-dialog.tsx (1)

119-133: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Verify amount/quota units vs min/step.

min={1} while step={QUOTA_PER_DOLLAR} — if QUOTA_PER_DOLLAR represents a much larger unit (e.g. 500000 per dollar), the native min constraint effectively allows sub-cent amounts inconsistent with the step granularity. This doesn't break the custom invalid check (which correctly gates on amount <= 0/> availableQuota), so it's cosmetic only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@web/default/src/features/wallet/components/dialogs/affiliate-withdrawal-dialog.tsx`
around lines 119 - 133, The withdrawal amount input in
affiliate-withdrawal-dialog.tsx has a unit mismatch between the native min
constraint and the QUOTA_PER_DOLLAR step granularity. Update the Input in the
affiliate-withdrawal-dialog component so its min value matches the smallest
valid quota increment (or remove the misleading native min if the custom
validation already handles bounds), keeping it consistent with QUOTA_PER_DOLLAR,
amount, and availableQuota.
controller/user.go (2)

457-483: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider bounding PaymentMethod/Account/Remark length before persisting.

Only presence is validated (req.Amount <= 0 || req.PaymentMethod == "" || req.Account == ""); there's no upper-bound length check on these client-supplied strings before they reach model.CreateAffiliateWithdrawal. If the underlying columns have size constraints, an oversized value could fail the insert with an opaque DB error instead of a clean validation message.

🛡️ Proposed length guard
 	req.PaymentMethod = strings.TrimSpace(req.PaymentMethod)
 	req.Account = strings.TrimSpace(req.Account)
 	req.Remark = strings.TrimSpace(req.Remark)
-	if req.Amount <= 0 || req.PaymentMethod == "" || req.Account == "" {
+	if req.Amount <= 0 || req.PaymentMethod == "" || req.Account == "" ||
+		len(req.PaymentMethod) > 64 || len(req.Account) > 128 || len(req.Remark) > 500 {
 		common.ApiErrorI18n(c, i18n.MsgInvalidParams)
 		return
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/user.go` around lines 457 - 483, Add upper-bound validation in
CreateAffiliateWithdrawal for the client-supplied fields before calling
model.CreateAffiliateWithdrawal: after trimming PaymentMethod, Account, and
Remark, reject values that exceed the database-safe lengths with ApiErrorI18n
and MsgInvalidParams. Keep the existing presence checks, and update the
validation near the AffiliateWithdrawalRequest handling so oversized inputs are
caught before persistence.

514-532: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Consider recording an audit trail for ProcessAffiliateWithdrawal.

This admin action mutates real quota balances (refunding on rejection) but, unlike UpdateUser's recordManageAuditFor(c, updatedUser.Id, "user.update", ...), it records no audit entry here. Given the compliance emphasis of this feature (payment-compliance gating elsewhere in the same file), logging who approved/rejected which withdrawal and why would aid dispute resolution and compliance review.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/user.go` around lines 514 - 532, ProcessAffiliateWithdrawal
updates withdrawal status and can affect real balances, but it currently does
not create an audit record like UpdateUser does with recordManageAuditFor. Add
an audit trail in ProcessAffiliateWithdrawal after a successful
model.UpdateAffiliateWithdrawalStatus call, using the admin identity from
c.GetInt("id") and the withdrawal/context details from req and id so
approvals/rejections and remarks are recorded for compliance review. Keep the
existing success/error handling intact and place the audit logging alongside the
status update flow so it runs only when the mutation succeeds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@controller/option.go`:
- Around line 151-160: The `affiliate_setting.reward_percent` validation in
`controller/option.go` accepts `NaN` because `strconv.ParseFloat` succeeds and
the existing range checks in the `case "affiliate_setting.reward_percent"` block
do not reject non-finite values. Update this branch to explicitly reject `NaN`
(and any other non-finite numeric input) before the 0–100 range check, while
keeping the existing compliance check and error handling intact. Use the
`option.Value` parsing path and the `common.ApiErrorMsg` / `common.ApiErrorI18n`
responses so invalid values are blocked consistently.

In `@controller/topup.go`:
- Around line 405-409: The top-up flow in IncreaseUserQuota currently applies
the user quota and then calls CreateAffiliateRebateForTopUp after the Epay
success response path, which can leave accounting partially applied if the
rebate fails. Move the topUp status update, quota credit, and affiliate rebate
creation into a single model transaction before returning success, or otherwise
enqueue a retryable affiliate-rebate job so the webhook is not acknowledged
until both the top-up and rebate steps are safely persisted.

In `@controller/user.go`:
- Around line 485-495: GetAffiliateWithdrawals is exposing admin-only fields
because it returns model.AffiliateWithdrawal objects directly. Update this
handler to map the results from model.GetUserAffiliateWithdrawals into a
user-facing DTO or filtered response before calling common.ApiSuccess, ensuring
admin_remark and processed_by are not serialized. Use GetAffiliateWithdrawals
and the model.AffiliateWithdrawal type as the key spots to adjust.

In `@main.go`:
- Around line 39-49: The embed directives in main.go are written as plain
comments instead of active directives, so the static assets will not be
included. Update the declarations for buildFS, indexPage, classicBuildFS, and
classicIndexPage to use the exact //go:embed syntax so the web entrypoint can
load its files correctly.

In `@model/affiliate.go`:
- Around line 336-343: The withdrawal logic in the affiliate debit flow is
non-atomic because the `AffQuota` check and the `Update` in the transaction can
race under concurrent withdrawals. Update the `affiliate` withdrawal path in
`model/affiliate.go` so the debit is done with a single conditional
`tx.Model(&User{})...Update(...)` guarded by `aff_quota >= amount`, then verify
`RowsAffected` and return `ErrAffiliateQuotaInsufficient` when no row was
updated. Keep the fix compatible with `tx`, `User`, and `gorm.Expr` so it works
across SQLite, MySQL 5.7.8+, and PostgreSQL 9.6+.
- Around line 376-392: The withdrawal update in the pending-claim flow is
race-prone because `tx.Save(&withdrawal)` relies on an in-memory status check in
`AffiliateWithdrawal`, so two admins can both process the same record. In the
same transaction, change the `status` transition to an atomic conditional update
using `WHERE id = ? AND status = ?` before doing any refund work, and only
continue when that update affects one row. Keep the refund logic in the same
path around this transition so `AffiliateWithdrawalStatusPending`,
`AffiliateWithdrawalStatusRejected`, and the `tx`-based processing remain safe
across supported databases.

In `@model/user.go`:
- Around line 348-358: The invite reward update in inviteUser is vulnerable to
lost updates because it loads a User, mutates fields in memory, and calls
DB.Save on a stale struct. Update the inviter counters atomically using
gorm.Expr in the inviteUser path, and avoid relying on the in-memory User fields
for AffCount, AffQuota, and AffHistoryQuota so concurrent registrations cannot
overwrite each other.

In `@router/api-router.go`:
- Around line 96-97: The new POST /aff_withdrawal route is missing the same
abuse protection used by other financial mutation endpoints. Update the route
registration in api-router.go for CreateAffiliateWithdrawal so it is wrapped
with middleware.CriticalRateLimit(), matching the neighboring withdraw/pay/topup
handlers in the same route group. Keep the change local to the selfRoute setup
so the GetAffiliateWithdrawals route remains unchanged.

In `@web/default/src/features/users/components/users-mutate-drawer.tsx`:
- Around line 507-534: The `affiliate_rule.reward_percent` field in
`users-mutate-drawer.tsx` is storing `NaN` during partial decimal input, which
makes the controlled `Input` render blank while typing. Update the `FormField`
render handler so `field.onChange` never receives `NaN` from
`event.currentTarget.valueAsNumber`; preserve the typed value for incomplete
numeric states or normalize invalid values before storing them. Also adjust the
`value={field.value ?? 0}` logic in this input so it handles `NaN` explicitly,
not just `null`/`undefined`.

In `@web/default/src/i18n/locales/ja.json`:
- Around line 518-526: The withdrawal dialog is reusing a shared “Payment
Method” translation that is too top-up specific in Japanese. Update the locale
entries in ja.json by adding a withdrawal-specific label or changing the
existing key to a neutral term like 支払い方法, and make sure the withdrawal flow
uses the correct key so the label in the withdrawal UI is not rendered as
チャージ方法.

---

Nitpick comments:
In `@controller/user.go`:
- Around line 457-483: Add upper-bound validation in CreateAffiliateWithdrawal
for the client-supplied fields before calling model.CreateAffiliateWithdrawal:
after trimming PaymentMethod, Account, and Remark, reject values that exceed the
database-safe lengths with ApiErrorI18n and MsgInvalidParams. Keep the existing
presence checks, and update the validation near the AffiliateWithdrawalRequest
handling so oversized inputs are caught before persistence.
- Around line 514-532: ProcessAffiliateWithdrawal updates withdrawal status and
can affect real balances, but it currently does not create an audit record like
UpdateUser does with recordManageAuditFor. Add an audit trail in
ProcessAffiliateWithdrawal after a successful
model.UpdateAffiliateWithdrawalStatus call, using the admin identity from
c.GetInt("id") and the withdrawal/context details from req and id so
approvals/rejections and remarks are recorded for compliance review. Keep the
existing success/error handling intact and place the audit logging alongside the
status update flow so it runs only when the mutation succeeds.

In
`@web/default/src/features/wallet/components/dialogs/affiliate-withdrawal-dialog.tsx`:
- Around line 119-133: The withdrawal amount input in
affiliate-withdrawal-dialog.tsx has a unit mismatch between the native min
constraint and the QUOTA_PER_DOLLAR step granularity. Update the Input in the
affiliate-withdrawal-dialog component so its min value matches the smallest
valid quota increment (or remove the misleading native min if the custom
validation already handles bounds), keeping it consistent with QUOTA_PER_DOLLAR,
amount, and availableQuota.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0046c723-2d85-4cb6-84a6-70a67c3b228b

📥 Commits

Reviewing files that changed from the base of the PR and between 52858ad and 2f105a8.

⛔ 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 (32)
  • controller/option.go
  • controller/topup.go
  • controller/user.go
  • main.go
  • model/affiliate.go
  • model/affiliate_test.go
  • model/main.go
  • model/redemption.go
  • model/task_cas_test.go
  • model/topup.go
  • model/user.go
  • router/api-router.go
  • setting/operation_setting/affiliate_setting.go
  • web/default/src/features/system-settings/billing/index.tsx
  • web/default/src/features/system-settings/billing/section-registry.tsx
  • web/default/src/features/system-settings/general/quota-settings-section.tsx
  • web/default/src/features/system-settings/types.ts
  • web/default/src/features/users/components/users-mutate-drawer.tsx
  • web/default/src/features/users/lib/user-form.ts
  • web/default/src/features/users/types.ts
  • web/default/src/features/wallet/api.ts
  • web/default/src/features/wallet/components/affiliate-rewards-card.tsx
  • web/default/src/features/wallet/components/dialogs/affiliate-withdrawal-dialog.tsx
  • web/default/src/features/wallet/hooks/use-affiliate.ts
  • web/default/src/features/wallet/index.tsx
  • web/default/src/features/wallet/types.ts
  • web/default/src/i18n/locales/en.json
  • web/default/src/i18n/locales/fr.json
  • web/default/src/i18n/locales/ja.json
  • web/default/src/i18n/locales/ru.json
  • web/default/src/i18n/locales/vi.json
  • web/default/src/i18n/locales/zh.json

Comment thread controller/option.go
Comment on lines +151 to +160
case "affiliate_setting.reward_percent":
percent, err := strconv.ParseFloat(strings.TrimSpace(option.Value.(string)), 64)
if err != nil || percent < 0 || percent > 100 {
common.ApiErrorMsg(c, "返利比例必须在 0 到 100 之间")
return
}
if percent > 0 && !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return
}

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

reward_percent validation lets "NaN" slip through.

strconv.ParseFloat parses "NaN" successfully (no error), and since NaN comparisons are always false, percent < 0 || percent > 100 never trips for it. An admin (or a raw API call bypassing the frontend Zod validation, which does reject NaN) could persist affiliate_setting.reward_percent = NaN, corrupting rebate percentage calculations that consume this setting downstream.

🐛 Proposed fix
 	case "affiliate_setting.reward_percent":
 		percent, err := strconv.ParseFloat(strings.TrimSpace(option.Value.(string)), 64)
-		if err != nil || percent < 0 || percent > 100 {
+		if err != nil || math.IsNaN(percent) || percent < 0 || percent > 100 {
 			common.ApiErrorMsg(c, "返利比例必须在 0 到 100 之间")
 			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
case "affiliate_setting.reward_percent":
percent, err := strconv.ParseFloat(strings.TrimSpace(option.Value.(string)), 64)
if err != nil || percent < 0 || percent > 100 {
common.ApiErrorMsg(c, "返利比例必须在 0 到 100 之间")
return
}
if percent > 0 && !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return
}
case "affiliate_setting.reward_percent":
percent, err := strconv.ParseFloat(strings.TrimSpace(option.Value.(string)), 64)
if err != nil || math.IsNaN(percent) || percent < 0 || percent > 100 {
common.ApiErrorMsg(c, "返利比例必须在 0 到 100 之间")
return
}
if percent > 0 && !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/option.go` around lines 151 - 160, The
`affiliate_setting.reward_percent` validation in `controller/option.go` accepts
`NaN` because `strconv.ParseFloat` succeeds and the existing range checks in the
`case "affiliate_setting.reward_percent"` block do not reject non-finite values.
Update this branch to explicitly reject `NaN` (and any other non-finite numeric
input) before the 0–100 range check, while keeping the existing compliance check
and error handling intact. Use the `option.Value` parsing path and the
`common.ApiErrorMsg` / `common.ApiErrorI18n` responses so invalid values are
blocked consistently.

Comment thread controller/topup.go
Comment on lines +405 to 409
if err == nil {
err = model.CreateAffiliateRebateForTopUp(topUp, quotaToAdd)
}
if err != nil {
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)))

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Don’t let an affiliate-only failure leave Epay accounting partially applied.

IncreaseUserQuota commits before CreateAffiliateRebateForTopUp, and the webhook has already returned "success". If rebate creation fails, the user is credited but the inviter rebate is missing with no provider retry. Move top-up status, quota credit, and rebate creation into one model transaction before acknowledging, or persist a retryable affiliate-rebate job instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/topup.go` around lines 405 - 409, The top-up flow in
IncreaseUserQuota currently applies the user quota and then calls
CreateAffiliateRebateForTopUp after the Epay success response path, which can
leave accounting partially applied if the rebate fails. Move the topUp status
update, quota credit, and affiliate rebate creation into a single model
transaction before returning success, or otherwise enqueue a retryable
affiliate-rebate job so the webhook is not acknowledged until both the top-up
and rebate steps are safely persisted.

Comment thread controller/user.go
Comment on lines +485 to +495
func GetAffiliateWithdrawals(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
withdrawals, total, err := model.GetUserAffiliateWithdrawals(c.GetInt("id"), pageInfo)
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(withdrawals)
common.ApiSuccess(c, pageInfo)
}

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.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect AffiliateWithdrawal struct json tags for AdminRemark/ProcessedBy
rg -n -A20 'type AffiliateWithdrawal struct' model/affiliate.go

Repository: QuantumNous/new-api

Length of output: 1073


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== model.GetUserAffiliateWithdrawals =="
rg -n -A40 'func GetUserAffiliateWithdrawals' model/affiliate.go

echo
echo "== page info response shape =="
rg -n -A40 'type PageInfo struct|type Page' common model controller | head -n 120

echo
echo "== controller GetAffiliateWithdrawals =="
rg -n -A20 'func GetAffiliateWithdrawals' controller/user.go

Repository: QuantumNous/new-api

Length of output: 4747


Hide admin-only withdrawal fields from this endpoint. GetAffiliateWithdrawals returns *model.AffiliateWithdrawal directly, so admin_remark and processed_by are serialized to the requester. Return a user-facing DTO or exclude those fields here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/user.go` around lines 485 - 495, GetAffiliateWithdrawals is
exposing admin-only fields because it returns model.AffiliateWithdrawal objects
directly. Update this handler to map the results from
model.GetUserAffiliateWithdrawals into a user-facing DTO or filtered response
before calling common.ApiSuccess, ensuring admin_remark and processed_by are not
serialized. Use GetAffiliateWithdrawals and the model.AffiliateWithdrawal type
as the key spots to adjust.

Comment thread main.go
Comment on lines +39 to 49
// go:embed web/default/dist
var buildFS embed.FS

//go:embed web/default/dist/index.html
// go:embed web/default/dist/index.html
var indexPage []byte

//go:embed web/classic/dist
// go:embed web/classic/dist
var classicBuildFS embed.FS

//go:embed web/classic/dist/index.html
// go:embed web/classic/dist/index.html
var classicIndexPage []byte

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the embed directives use the exact `//go:embed` form.
sed -n '39,49p' main.go

Repository: QuantumNous/new-api

Length of output: 396


Restore the exact //go:embed syntax.

// go:embed is a plain comment, so these assets won’t be embedded and the web entrypoint will fail to load its static files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main.go` around lines 39 - 49, The embed directives in main.go are written as
plain comments instead of active directives, so the static assets will not be
included. Update the declarations for buildFS, indexPage, classicBuildFS, and
classicIndexPage to use the exact //go:embed syntax so the web entrypoint can
load its files correctly.

Comment thread model/affiliate.go
Comment on lines +336 to +343
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", userId).First(&user).Error; err != nil {
return err
}
if user.AffQuota < amount {
return ErrAffiliateQuotaInsufficient
}
if err := tx.Model(&User{}).Where("id = ?", userId).Update("aff_quota", gorm.Expr("aff_quota - ?", amount)).Error; err != nil {
return err

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make the withdrawal debit atomic.

The balance check and debit are split, so concurrent withdrawals can both pass the AffQuota check and overdraw the affiliate balance. Use a conditional update and check RowsAffected.

Proposed fix
-		var user User
-		if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", userId).First(&user).Error; err != nil {
-			return err
-		}
-		if user.AffQuota < amount {
-			return ErrAffiliateQuotaInsufficient
-		}
-		if err := tx.Model(&User{}).Where("id = ?", userId).Update("aff_quota", gorm.Expr("aff_quota - ?", amount)).Error; err != nil {
-			return err
-		}
+		res := tx.Model(&User{}).
+			Where("id = ? AND aff_quota >= ?", userId, amount).
+			Update("aff_quota", gorm.Expr("aff_quota - ?", amount))
+		if res.Error != nil {
+			return res.Error
+		}
+		if res.RowsAffected == 0 {
+			return ErrAffiliateQuotaInsufficient
+		}
 		return tx.Create(withdrawal).Error

As per coding guidelines, all database code must support SQLite, MySQL >= 5.7.8, and PostgreSQL >= 9.6 simultaneously.

📝 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
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", userId).First(&user).Error; err != nil {
return err
}
if user.AffQuota < amount {
return ErrAffiliateQuotaInsufficient
}
if err := tx.Model(&User{}).Where("id = ?", userId).Update("aff_quota", gorm.Expr("aff_quota - ?", amount)).Error; err != nil {
return err
res := tx.Model(&User{}).
Where("id = ? AND aff_quota >= ?", userId, amount).
Update("aff_quota", gorm.Expr("aff_quota - ?", amount))
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrAffiliateQuotaInsufficient
}
return tx.Create(withdrawal).Error
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@model/affiliate.go` around lines 336 - 343, The withdrawal logic in the
affiliate debit flow is non-atomic because the `AffQuota` check and the `Update`
in the transaction can race under concurrent withdrawals. Update the `affiliate`
withdrawal path in `model/affiliate.go` so the debit is done with a single
conditional `tx.Model(&User{})...Update(...)` guarded by `aff_quota >= amount`,
then verify `RowsAffected` and return `ErrAffiliateQuotaInsufficient` when no
row was updated. Keep the fix compatible with `tx`, `User`, and `gorm.Expr` so
it works across SQLite, MySQL 5.7.8+, and PostgreSQL 9.6+.

Source: Coding guidelines

Comment thread model/affiliate.go
Comment on lines +376 to +392
var withdrawal AffiliateWithdrawal
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", id).First(&withdrawal).Error; err != nil {
return err
}
if withdrawal.Status != AffiliateWithdrawalStatusPending {
return ErrAffiliateWithdrawalInvalid
}
if status == AffiliateWithdrawalStatusRejected {
if err := tx.Model(&User{}).Where("id = ?", withdrawal.UserId).Update("aff_quota", gorm.Expr("aff_quota + ?", withdrawal.Amount)).Error; err != nil {
return err
}
}
withdrawal.Status = status
withdrawal.AdminRemark = adminRemark
withdrawal.ProcessedAt = common.GetTimestamp()
withdrawal.ProcessedBy = operatorId
return tx.Save(&withdrawal).Error

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Claim the pending withdrawal with a conditional status transition.

Two admins can process the same pending withdrawal concurrently and both pass the in-memory status check, which can double-refund on rejection. Update status with WHERE id = ? AND status = 'pending' first, then refund only when that transition succeeds.

Proposed fix
 		var withdrawal AffiliateWithdrawal
-		if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", id).First(&withdrawal).Error; err != nil {
+		if err := tx.Select("id", "user_id", "amount").Where("id = ?", id).First(&withdrawal).Error; err != nil {
 			return err
 		}
-		if withdrawal.Status != AffiliateWithdrawalStatusPending {
+
+		res := tx.Model(&AffiliateWithdrawal{}).
+			Where("id = ? AND status = ?", id, AffiliateWithdrawalStatusPending).
+			Updates(map[string]interface{}{
+				"status":       status,
+				"admin_remark": adminRemark,
+				"processed_at": common.GetTimestamp(),
+				"processed_by": operatorId,
+			})
+		if res.Error != nil {
+			return res.Error
+		}
+		if res.RowsAffected == 0 {
 			return ErrAffiliateWithdrawalInvalid
 		}
 		if status == AffiliateWithdrawalStatusRejected {
 			if err := tx.Model(&User{}).Where("id = ?", withdrawal.UserId).Update("aff_quota", gorm.Expr("aff_quota + ?", withdrawal.Amount)).Error; err != nil {
 				return err
 			}
 		}
-		withdrawal.Status = status
-		withdrawal.AdminRemark = adminRemark
-		withdrawal.ProcessedAt = common.GetTimestamp()
-		withdrawal.ProcessedBy = operatorId
-		return tx.Save(&withdrawal).Error
+		return nil

As per coding guidelines, all database code must support SQLite, MySQL >= 5.7.8, and PostgreSQL >= 9.6 simultaneously.

📝 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 withdrawal AffiliateWithdrawal
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", id).First(&withdrawal).Error; err != nil {
return err
}
if withdrawal.Status != AffiliateWithdrawalStatusPending {
return ErrAffiliateWithdrawalInvalid
}
if status == AffiliateWithdrawalStatusRejected {
if err := tx.Model(&User{}).Where("id = ?", withdrawal.UserId).Update("aff_quota", gorm.Expr("aff_quota + ?", withdrawal.Amount)).Error; err != nil {
return err
}
}
withdrawal.Status = status
withdrawal.AdminRemark = adminRemark
withdrawal.ProcessedAt = common.GetTimestamp()
withdrawal.ProcessedBy = operatorId
return tx.Save(&withdrawal).Error
var withdrawal AffiliateWithdrawal
if err := tx.Select("id", "user_id", "amount").Where("id = ?", id).First(&withdrawal).Error; err != nil {
return err
}
res := tx.Model(&AffiliateWithdrawal{}).
Where("id = ? AND status = ?", id, AffiliateWithdrawalStatusPending).
Updates(map[string]interface{}{
"status": status,
"admin_remark": adminRemark,
"processed_at": common.GetTimestamp(),
"processed_by": operatorId,
})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrAffiliateWithdrawalInvalid
}
if status == AffiliateWithdrawalStatusRejected {
if err := tx.Model(&User{}).Where("id = ?", withdrawal.UserId).Update("aff_quota", gorm.Expr("aff_quota + ?", withdrawal.Amount)).Error; err != nil {
return err
}
}
return nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@model/affiliate.go` around lines 376 - 392, The withdrawal update in the
pending-claim flow is race-prone because `tx.Save(&withdrawal)` relies on an
in-memory status check in `AffiliateWithdrawal`, so two admins can both process
the same record. In the same transaction, change the `status` transition to an
atomic conditional update using `WHERE id = ? AND status = ?` before doing any
refund work, and only continue when that update affects one row. Keep the refund
logic in the same path around this transition so
`AffiliateWithdrawalStatusPending`, `AffiliateWithdrawalStatusRejected`, and the
`tx`-based processing remain safe across supported databases.

Source: Coding guidelines

Comment thread model/user.go
Comment on lines +348 to 358
func inviteUser(inviterId int, rewardQuota int) (err error) {
user, err := GetUserById(inviterId, true)
if err != nil {
return err
}
user.AffCount++
user.AffQuota += common.QuotaForInviter
user.AffHistoryQuota += common.QuotaForInviter
if rewardQuota > 0 {
user.AffQuota += rewardQuota
user.AffHistoryQuota += rewardQuota
}
return DB.Save(user).Error

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use atomic increments for invite rewards.

Concurrent registrations for the same inviter can read the same row, increment in memory, and Save one update over the other. Update counters with gorm.Expr instead of saving a stale User struct.

Proposed fix
 func inviteUser(inviterId int, rewardQuota int) (err error) {
-	user, err := GetUserById(inviterId, true)
-	if err != nil {
-		return err
+	updates := map[string]interface{}{
+		"aff_count": gorm.Expr("aff_count + ?", 1),
 	}
-	user.AffCount++
 	if rewardQuota > 0 {
-		user.AffQuota += rewardQuota
-		user.AffHistoryQuota += rewardQuota
+		updates["aff_quota"] = gorm.Expr("aff_quota + ?", rewardQuota)
+		updates["aff_history"] = gorm.Expr("aff_history + ?", rewardQuota)
 	}
-	return DB.Save(user).Error
+	res := DB.Model(&User{}).Where("id = ?", inviterId).Updates(updates)
+	if res.Error != nil {
+		return res.Error
+	}
+	if res.RowsAffected == 0 {
+		return errors.New("inviter user not found")
+	}
+	return nil
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@model/user.go` around lines 348 - 358, The invite reward update in inviteUser
is vulnerable to lost updates because it loads a User, mutates fields in memory,
and calls DB.Save on a stale struct. Update the inviter counters atomically
using gorm.Expr in the inviteUser path, and avoid relying on the in-memory User
fields for AffCount, AffQuota, and AffHistoryQuota so concurrent registrations
cannot overwrite each other.

Comment thread router/api-router.go
Comment on lines +96 to +97
selfRoute.GET("/aff_withdrawal/self", controller.GetAffiliateWithdrawals)
selfRoute.POST("/aff_withdrawal", controller.CreateAffiliateWithdrawal)

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

POST /aff_withdrawal lacks rate limiting, unlike sibling financial write routes.

Every other user-initiated financial mutation on this route group (/topup, /pay, /stripe/pay, /creem/pay, /waffo/pay, etc.) is guarded with middleware.CriticalRateLimit(), but the new CreateAffiliateWithdrawal route is not. While the model layer serializes concurrent withdrawals via row locking, an unthrottled endpoint still allows abusive request volume (e.g. hammering the DB with repeated transactions).

🔒️ Proposed fix
-				selfRoute.GET("/aff_withdrawal/self", controller.GetAffiliateWithdrawals)
-				selfRoute.POST("/aff_withdrawal", controller.CreateAffiliateWithdrawal)
+				selfRoute.GET("/aff_withdrawal/self", controller.GetAffiliateWithdrawals)
+				selfRoute.POST("/aff_withdrawal", middleware.CriticalRateLimit(), controller.CreateAffiliateWithdrawal)
📝 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
selfRoute.GET("/aff_withdrawal/self", controller.GetAffiliateWithdrawals)
selfRoute.POST("/aff_withdrawal", controller.CreateAffiliateWithdrawal)
selfRoute.GET("/aff_withdrawal/self", controller.GetAffiliateWithdrawals)
selfRoute.POST("/aff_withdrawal", middleware.CriticalRateLimit(), controller.CreateAffiliateWithdrawal)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/api-router.go` around lines 96 - 97, The new POST /aff_withdrawal
route is missing the same abuse protection used by other financial mutation
endpoints. Update the route registration in api-router.go for
CreateAffiliateWithdrawal so it is wrapped with middleware.CriticalRateLimit(),
matching the neighboring withdraw/pay/topup handlers in the same route group.
Keep the change local to the selfRoute setup so the GetAffiliateWithdrawals
route remains unchanged.

Comment on lines +507 to +534
<FormField
control={form.control}
name='affiliate_rule.reward_percent'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Referral Rebate Percent')}
</FormLabel>
<FormControl>
<Input
type='number'
min={0}
max={100}
step='0.01'
value={field.value ?? 0}
onChange={(event) =>
field.onChange(
event.target.value === ''
? 0
: event.currentTarget.valueAsNumber
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reward-percent input can render blank/NaN mid-typing.

onChange only special-cases an empty string; when the native input has an incomplete/invalid numeric string (e.g. typing "12.", ".", "-"), valueAsNumber is NaN. That NaN is then stored via field.onChange(NaN), and on re-render value={field.value ?? 0} doesn't catch it since ?? only substitutes for null/undefined, not NaN — so the controlled input receives an invalid value prop, effectively blanking the field while the admin is typing a decimal value.

🐛 Proposed fix
                             <Input
                                type='number'
                                min={0}
                                max={100}
                                step='0.01'
                                value={field.value ?? 0}
                                onChange={(event) =>
                                  field.onChange(
-                                    event.target.value === ''
-                                      ? 0
-                                      : event.currentTarget.valueAsNumber
+                                    event.target.value === '' ||
+                                      Number.isNaN(event.currentTarget.valueAsNumber)
+                                      ? 0
+                                      : event.currentTarget.valueAsNumber
                                  )
                                }
                              />
📝 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
<FormField
control={form.control}
name='affiliate_rule.reward_percent'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Referral Rebate Percent')}
</FormLabel>
<FormControl>
<Input
type='number'
min={0}
max={100}
step='0.01'
value={field.value ?? 0}
onChange={(event) =>
field.onChange(
event.target.value === ''
? 0
: event.currentTarget.valueAsNumber
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='affiliate_rule.reward_percent'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Referral Rebate Percent')}
</FormLabel>
<FormControl>
<Input
type='number'
min={0}
max={100}
step='0.01'
value={field.value ?? 0}
onChange={(event) =>
field.onChange(
event.target.value === '' ||
Number.isNaN(event.currentTarget.valueAsNumber)
? 0
: event.currentTarget.valueAsNumber
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/default/src/features/users/components/users-mutate-drawer.tsx` around
lines 507 - 534, The `affiliate_rule.reward_percent` field in
`users-mutate-drawer.tsx` is storing `NaN` during partial decimal input, which
makes the controlled `Input` render blank while typing. Update the `FormField`
render handler so `field.onChange` never receives `NaN` from
`event.currentTarget.valueAsNumber`; preserve the typed value for incomplete
numeric states or normalize invalid values before storing them. Also adjust the
`value={field.value ?? 0}` logic in this input so it handles `NaN` explicitly,
not just `null`/`undefined`.

Comment on lines +518 to +526
"Withdraw": "出金",
"Withdraw Referral Rewards": "紹介報酬を出金",
"Withdrawal Amount": "出金額",
"Withdrawal request failed": "出金申請に失敗しました",
"Withdrawal request submitted": "出金申請を送信しました",
"Submit a withdrawal request for available referral rewards": "利用可能な紹介報酬の出金申請を送信します",
"Receiving Account": "受取口座",
"Bank transfer, PayPal, Alipay...": "銀行振込、PayPal、Alipay...",
"Account, email, or wallet address": "口座、メール、またはウォレットアドレス",

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify how many places reuse the "Payment Method" i18n key and whether
# a top-up-specific vs. withdrawal-specific label distinction already exists.

rg -n "t\('Payment Method'\)" web/default/src -C 3
rg -n '"Payment Method"' web/default/src/i18n/locales/ja.json

Repository: QuantumNous/new-api

Length of output: 3788


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether a withdrawal-specific payment-method label already exists
rg -n '"(Withdrawal|Withdraw).*Method"|Payment Method|Payout Method|Receiving Account|受取口座|チャージ方法|支払い方法' web/default/src/i18n/locales/ja.json web/default/src -g '!**/node_modules/**' -C 1

Repository: QuantumNous/new-api

Length of output: 20283


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path("web/default/src/i18n/locales/ja.json")
text = p.read_text(encoding="utf-8")
for key in ["Payment Method", "Withdrawal request failed", "Withdrawal request submitted", "Receiving Account"]:
    idx = text.find(f'"{key}"')
    if idx != -1:
        start = max(0, idx - 120)
        end = min(len(text), idx + 180)
        print(f"\n--- {key} ---")
        print(text[start:end])
PY

Repository: QuantumNous/new-api

Length of output: 1485


Use a withdrawal-specific label for this field.
Payment Method is shared by recharge, billing, and withdrawal flows, but the Japanese translation is チャージ方法 (“top-up method”), which is wrong in the withdrawal dialog. Add a separate withdrawal key or switch this label to a neutral term like 支払い方法.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/default/src/i18n/locales/ja.json` around lines 518 - 526, The withdrawal
dialog is reusing a shared “Payment Method” translation that is too top-up
specific in Japanese. Update the locale entries in ja.json by adding a
withdrawal-specific label or changing the existing key to a neutral term like
支払い方法, and make sure the withdrawal flow uses the correct key so the label in
the withdrawal UI is not rendered as チャージ方法.

JacksonsY added a commit to JacksonsY/new-api that referenced this pull request Jul 4, 2026
上游本轮全部为前端与依赖更新:渠道测试弹窗交互/布局精简、模型测试行操作紧凑化、
ai-elements 嵌套 usage token 读取修复、web 依赖升级(移除 date-fns 等)。
无后端变更;PR QuantumNous#5856(充值返佣提现)仍未合入,无提现基建撞车。
与本地二开(jzlh)改动文件零交集,无冲突。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	web/bun.lock
#	web/default/package.json
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.

1 participant