feat(payment): 新增 Jeepay 聚合支付充值接入 - #4099
Conversation
- Add Jeepay unified order creation with MD5 signature - Support multiple pay modes: QR_CASHIER, WEB_CASHIER, WX_NATIVE, ALI_QR - Add async notification handler with JSON/form/query parsing - Add order status polling endpoint - Add QR code payment modal with countdown and expiry - Add admin Jeepay settings page - Add unit tests for signature and notification flow
临时错误(超时、网络)不应将订单标记为 failed, 因为 webhook 可能仍会到达,而 RechargeJeepay 仅处理 pending 订单。 同时修复测试中 Redis 未初始化导致的 panic。
WX_NATIVE 的 codeUrl 使用 weixin:// scheme,优先提取 codeUrl 且不要求 HTTP 前缀,payUrl 和 cashierUrl 仍保持 HTTP 校验。
使用 getJeepayMinTopup() 和 getJeepayOrderTimeoutMinutes() 替代 原始 setting 值,确保前端获取到与后端一致的有效限额。
避免 JeepayMinTopUp/JeepayOrderTimeoutMinutes 在数据库值 格式异常时被静默置为 0,改为返回解析错误以便记录日志。
failed 和 expired 状态均调用 markExpired() 进入终态, 避免订单被拒后二维码仍然可扫。
将 jeepayWayCodeOptions 移入组件内以使用 t() 包裹标签, 占位符统一使用中文源字符串,符合项目 i18n 规范。
WalkthroughThis pull request integrates Jeepay payment gateway support into the platform by adding backend controllers for payment requests and webhook handling, configurable settings, database models for transaction completion, new API routes, and frontend components for payment flows including QR code displays and status polling. Changes
Sequence DiagramsequenceDiagram
actor User as User
participant FE as Frontend
participant BE as Backend
participant JP as Jeepay API
participant WH as Webhook Handler
User->>FE: Initiate Jeepay top-up
FE->>BE: POST /api/user/jeepay/pay<br/>(paymentMethod, amount, wayCode)
BE->>JP: POST unified-order<br/>(mchNo, appId, signature, amount, timeout)
JP-->>BE: Return paymentUrl, qrUrl
BE-->>FE: Return urls + orderId + expireAt
alt QR Payment (QR_CASHIER, WX_NATIVE, ALI_QR)
FE->>FE: Display QR Code Modal
FE->>BE: GET /api/user/jeepay/status/:orderId<br/>(polling every 3s)
BE-->>FE: Return pending/success/failed/expired
else Web Payment
FE->>JP: Open paymentUrl in new tab
end
JP->>WH: POST /api/jeepay/notify<br/>(state, amount, sign, orderId)
WH->>BE: Verify MD5 signature
BE->>BE: RechargeJeepay(tradeNo)<br/>Lock order, update status,<br/>increment quota
WH-->>JP: Return "success"
BE-->>FE: Status poll returns success
FE->>FE: Show success, close modal
User->>User: Quota updated
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
web/src/components/topup/index.jsx (1)
511-533:⚠️ Potential issue | 🟠 MajorKeep
minTopUppayment-method specific.Lines 515-523 choose a single shared minimum by gateway priority, but Lines 199-201 and 222-224 validate every payment against that shared
minTopUp. If online top-up and Jeepay are both enabled, the UI can still open the Jeepay flow belowdata.jeepay_min_topup, and Lines 168-170 ofcontroller/topup_jeepay.gothen reject it server-side.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/topup/index.jsx` around lines 511 - 533, The code currently computes a single shared minTopUpValue (minTopUpValue) and calls setMinTopUp/setTopUpCount with it, which causes cross-gateway validation issues; instead compute and store per-method minimums from data (e.g. jeepay_min_topup, stripe_min_topup, min_topup for online, waffo_min_topup) and set corresponding state variables (e.g. setMinTopUpJeepay, setMinTopUpStripe, setMinTopUpOnline, setWaffoMinTopUp) while leaving setMinTopUp for a UI-wide fallback only; update the initial setTopUpCount to use the minimum appropriate to the currently selected gateway (or the smallest of enabled minima if none selected) and ensure the per-gateway validation code that reads minTopUp (used in the Jeepay/Stripe/online flows) uses the new method-specific state names instead of the shared minTopUpValue.
🧹 Nitpick comments (3)
router/api-router.go (1)
94-95: Consider adding rate limiting to the status polling endpoint.The
/jeepay/payendpoint correctly usesCriticalRateLimit(), but the/jeepay/status/:tradeNoendpoint has no rate limiting. While it's protected byUserAuth(), a malicious authenticated user could still abuse the polling endpoint. Consider adding a lighter rate limit (e.g.,SearchRateLimit()) to prevent excessive polling.💡 Suggested change
selfRoute.POST("/jeepay/pay", middleware.CriticalRateLimit(), controller.RequestJeepayPay) -selfRoute.GET("/jeepay/status/:tradeNo", controller.GetJeepayPayStatus) +selfRoute.GET("/jeepay/status/:tradeNo", middleware.SearchRateLimit(), controller.GetJeepayPayStatus)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@router/api-router.go` around lines 94 - 95, Add a lighter rate limit middleware to the Jeepay status polling route to prevent abusive polling: update the route that registers controller.GetJeepayPayStatus (the selfRoute.GET("/jeepay/status/:tradeNo", controller.GetJeepayPayStatus) line) to include a suitable limiter like middleware.SearchRateLimit() (or another defined non-critical limiter) before the handler so the endpoint stays protected by UserAuth() while enforcing a lower request rate.web/src/components/topup/modals/JeepayQRCodeModal.jsx (1)
8-19: UseuseTranslation()inside the modal.Receiving
tas a prop here couples this leaf component to its parent and keeps the modal off the repo’s standard i18n path. Resolve translations locally and droptfrom this component’s public API instead.As per coding guidelines, "Frontend i18n: Use
i18next+react-i18next+i18next-browser-languagedetector. Translation files inweb/src/i18n/locales/{lang}.jsonmust be flat JSON with Chinese source strings as keys. UseuseTranslation()hook and callt('中文key')in components."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/topup/modals/JeepayQRCodeModal.jsx` around lines 8 - 19, The component JeepayQRCodeModal should stop accepting the translation function prop t and instead import and call useTranslation() inside the component; remove t from the prop list in the JeepayQRCodeModal signature and any places that pass it in, add import { useTranslation } from 'react-i18next' and call const { t } = useTranslation() at the top of the component, then replace any parent-provided t(...) uses with local t('中文 key') calls that match the flat JSON keys in web/src/i18n/locales/{lang}.json; ensure the public API (props: visible, onCancel, qrCodeUrl, orderId, wayCode, money, expiredTime, expireAt, onPaid) no longer includes t and update all callers to stop passing t.controller/topup_jeepay_test.go (1)
55-165: Cover the form/query notify branches too.
parseJeepayNotifyPayloadincontroller/topup_jeepay.gohas separate JSON, form, and query parsing paths, but this suite only exercises JSON requests. Adding oneapplication/x-www-form-urlencodedcase and one query-string case would keep the advertised compatibility from regressing silently.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@controller/topup_jeepay_test.go` around lines 55 - 165, Add two tests that exercise the form and query parsing branches used by parseJeepayNotifyPayload: create copies of TestJeepayNotify that build payloads as (1) application/x-www-form-urlencoded (use url.Values, encode to body, set Content-Type to "application/x-www-form-urlencoded") and (2) query-string params (attach the same key/value pairs to the request URL). In both cases include the same required fields (mchNo, appId, mchOrderNo, state, amount, signType, timestamps as needed), compute the signature with buildJeepaySign using settingJeepayApiKeyForTest, and assert the handler JeepayNotify returns http.StatusOK/"success" and that model.GetTopUpByTradeNo shows Status==common.TopUpStatusSuccess and the user quota updated; use the existing helpers (setupJeepayTestDB, settingJeepayForTest, buildJeepaySign) and the same trade numbers to locate records.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@controller/topup_jeepay_test.go`:
- Around line 18-34: The helper setupJeepayTestDB mutates globals (model.DB,
model.LOG_DB, common.UsingSQLite, common.UsingMySQL, common.UsingPostgreSQL,
common.RedisEnabled, common.LogConsumeEnabled, common.QuotaPerUnit and the
setting.Jeepay* globals referenced later) without restoring them; update
setupJeepayTestDB (and the other helper at the other block) to capture the
original values at the start and call t.Cleanup to restore each original value
(close and nil the temporary sqlite DB and reassign model.DB/model.LOG_DB and
each common.* and setting.Jeepay* back to their originals) so tests no longer
leak state or become order-dependent.
In `@controller/topup_jeepay.go`:
- Around line 268-275: Compute and persist a single canonical expiry when the
Jeepay order is created and use that persisted value for both response payloads
instead of computing expire_at from time.Now() or recomputing from CreateTime +
getJeepayExpiredTime(); specifically, when creating the order save an explicit
ExpireAt (e.g., derived from the upstream order response or from
topUp.CreateTime + getJeepayExpiredTime()) and then replace the ephemeral
calculation that sets expireAt in responseData (payment_url/expire_at block) and
the later logic that uses topUp.CreateTime + getJeepayExpiredTime() to read that
persisted ExpireAt field so both endpoints return the identical expiry.
In `@web/src/components/topup/modals/JeepayQRCodeModal.jsx`:
- Around line 82-117: The pollStatus function can overlap when setInterval fires
before a prior async API.get completes; add a reentrancy guard (e.g., a local
ref like isPollingRef or inFlight boolean) and early-return if a poll is already
in progress, ensure you set isPollingRef.current = true before awaiting API.get
and set it back to false in a finally block; also when you detect terminal
states ('success', 'failed', 'expired') clear the pollTimerRef and set
isPollingRef.current = false before calling Toast.success, showError, or onPaid
to avoid duplicate callbacks; update references to pollStatus, pollTimerRef,
expireAtRef, markExpired, onPaid, Toast.success, and showError accordingly.
---
Outside diff comments:
In `@web/src/components/topup/index.jsx`:
- Around line 511-533: The code currently computes a single shared minTopUpValue
(minTopUpValue) and calls setMinTopUp/setTopUpCount with it, which causes
cross-gateway validation issues; instead compute and store per-method minimums
from data (e.g. jeepay_min_topup, stripe_min_topup, min_topup for online,
waffo_min_topup) and set corresponding state variables (e.g. setMinTopUpJeepay,
setMinTopUpStripe, setMinTopUpOnline, setWaffoMinTopUp) while leaving
setMinTopUp for a UI-wide fallback only; update the initial setTopUpCount to use
the minimum appropriate to the currently selected gateway (or the smallest of
enabled minima if none selected) and ensure the per-gateway validation code that
reads minTopUp (used in the Jeepay/Stripe/online flows) uses the new
method-specific state names instead of the shared minTopUpValue.
---
Nitpick comments:
In `@controller/topup_jeepay_test.go`:
- Around line 55-165: Add two tests that exercise the form and query parsing
branches used by parseJeepayNotifyPayload: create copies of TestJeepayNotify
that build payloads as (1) application/x-www-form-urlencoded (use url.Values,
encode to body, set Content-Type to "application/x-www-form-urlencoded") and (2)
query-string params (attach the same key/value pairs to the request URL). In
both cases include the same required fields (mchNo, appId, mchOrderNo, state,
amount, signType, timestamps as needed), compute the signature with
buildJeepaySign using settingJeepayApiKeyForTest, and assert the handler
JeepayNotify returns http.StatusOK/"success" and that model.GetTopUpByTradeNo
shows Status==common.TopUpStatusSuccess and the user quota updated; use the
existing helpers (setupJeepayTestDB, settingJeepayForTest, buildJeepaySign) and
the same trade numbers to locate records.
In `@router/api-router.go`:
- Around line 94-95: Add a lighter rate limit middleware to the Jeepay status
polling route to prevent abusive polling: update the route that registers
controller.GetJeepayPayStatus (the selfRoute.GET("/jeepay/status/:tradeNo",
controller.GetJeepayPayStatus) line) to include a suitable limiter like
middleware.SearchRateLimit() (or another defined non-critical limiter) before
the handler so the endpoint stays protected by UserAuth() while enforcing a
lower request rate.
In `@web/src/components/topup/modals/JeepayQRCodeModal.jsx`:
- Around line 8-19: The component JeepayQRCodeModal should stop accepting the
translation function prop t and instead import and call useTranslation() inside
the component; remove t from the prop list in the JeepayQRCodeModal signature
and any places that pass it in, add import { useTranslation } from
'react-i18next' and call const { t } = useTranslation() at the top of the
component, then replace any parent-provided t(...) uses with local t('中文 key')
calls that match the flat JSON keys in web/src/i18n/locales/{lang}.json; ensure
the public API (props: visible, onCancel, qrCodeUrl, orderId, wayCode, money,
expiredTime, expireAt, onPaid) no longer includes t and update all callers to
stop passing t.
🪄 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: 0e5c5ad1-258a-4ef7-8f11-d603f108a46a
⛔ Files ignored due to path filters (2)
web/bun.lockis excluded by!**/*.lockweb/src/assets/jeepay.svgis excluded by!**/*.svg
📒 Files selected for processing (14)
controller/topup.gocontroller/topup_jeepay.gocontroller/topup_jeepay_test.gomodel/option.gomodel/topup.gorouter/api-router.gosetting/payment_jeepay.goweb/src/components/settings/PaymentSetting.jsxweb/src/components/topup/RechargeCard.jsxweb/src/components/topup/index.jsxweb/src/components/topup/modals/JeepayQRCodeModal.jsxweb/src/components/topup/modals/PaymentConfirmModal.jsxweb/src/components/topup/modals/TopupHistoryModal.jsxweb/src/pages/Setting/Payment/SettingsPaymentGatewayJeepay.jsx
| func setupJeepayTestDB(t *testing.T) { | ||
| t.Helper() | ||
|
|
||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | ||
| require.NoError(t, err) | ||
|
|
||
| model.DB = db | ||
| model.LOG_DB = db | ||
| common.UsingSQLite = true | ||
| common.UsingMySQL = false | ||
| common.UsingPostgreSQL = false | ||
| common.RedisEnabled = false | ||
| common.LogConsumeEnabled = true | ||
| common.QuotaPerUnit = 1 | ||
|
|
||
| require.NoError(t, db.AutoMigrate(&model.User{}, &model.TopUp{}, &model.Log{})) | ||
| } |
There was a problem hiding this comment.
Restore mutated globals with t.Cleanup().
These helpers overwrite model.DB, model.LOG_DB, multiple common.* flags, and setting.Jeepay* globals without restoring them. That makes this file order-dependent with other controller package tests.
Also applies to: 173-178
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@controller/topup_jeepay_test.go` around lines 18 - 34, The helper
setupJeepayTestDB mutates globals (model.DB, model.LOG_DB, common.UsingSQLite,
common.UsingMySQL, common.UsingPostgreSQL, common.RedisEnabled,
common.LogConsumeEnabled, common.QuotaPerUnit and the setting.Jeepay* globals
referenced later) without restoring them; update setupJeepayTestDB (and the
other helper at the other block) to capture the original values at the start and
call t.Cleanup to restore each original value (close and nil the temporary
sqlite DB and reassign model.DB/model.LOG_DB and each common.* and
setting.Jeepay* back to their originals) so tests no longer leak state or become
order-dependent.
| expireAt := time.Now().Add(time.Duration(orderReq.ExpiredTime) * time.Second).Unix() | ||
| responseData := gin.H{ | ||
| "payment_url": paymentURL, | ||
| "order_id": tradeNo, | ||
| "way_code": orderReq.WayCode, | ||
| "money": payMoney, | ||
| "expired_time": orderReq.ExpiredTime, | ||
| "expire_at": expireAt, |
There was a problem hiding this comment.
Use one persisted expiry source for both endpoints.
Lines 268-275 return expire_at from time.Now() after the upstream order call completes, but Lines 301-305 later derive expiry from topUp.CreateTime + getJeepayExpiredTime(). Those values can drift on slow upstream calls and whenever JeepayOrderTimeoutMinutes changes after the order is created, so the modal countdown and the status API can disagree.
Also applies to: 301-305
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@controller/topup_jeepay.go` around lines 268 - 275, Compute and persist a
single canonical expiry when the Jeepay order is created and use that persisted
value for both response payloads instead of computing expire_at from time.Now()
or recomputing from CreateTime + getJeepayExpiredTime(); specifically, when
creating the order save an explicit ExpireAt (e.g., derived from the upstream
order response or from topUp.CreateTime + getJeepayExpiredTime()) and then
replace the ephemeral calculation that sets expireAt in responseData
(payment_url/expire_at block) and the later logic that uses topUp.CreateTime +
getJeepayExpiredTime() to read that persisted ExpireAt field so both endpoints
return the identical expiry.
| const pollStatus = async () => { | ||
| if (Date.now() > expireAtRef.current) { | ||
| markExpired(); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const res = await API.get(`/api/user/jeepay/status/${encodeURIComponent(orderId)}`); | ||
| if (!res?.data?.success) { | ||
| return; | ||
| } | ||
| const status = res.data?.data?.status; | ||
| if (status === 'success') { | ||
| if (pollTimerRef.current) { | ||
| clearInterval(pollTimerRef.current); | ||
| pollTimerRef.current = null; | ||
| } | ||
| Toast.success({ content: t('支付成功') }); | ||
| onPaid?.(); | ||
| } else if (status === 'failed' || status === 'expired') { | ||
| if (pollTimerRef.current) { | ||
| clearInterval(pollTimerRef.current); | ||
| pollTimerRef.current = null; | ||
| } | ||
| markExpired(); | ||
| if (status === 'failed') { | ||
| showError(t('订单状态已变更,请重新下单')); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| // ignore transient polling errors | ||
| } | ||
| }; | ||
|
|
||
| pollStatus(); | ||
| pollTimerRef.current = setInterval(pollStatus, 3000); |
There was a problem hiding this comment.
Prevent overlapping status polls.
Lines 82-117 run an async pollStatus under setInterval, so a slow request can still be in flight when the next tick starts. That makes duplicate Toast.success, showError, or onPaid calls possible after the modal has already reached a terminal state.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/components/topup/modals/JeepayQRCodeModal.jsx` around lines 82 - 117,
The pollStatus function can overlap when setInterval fires before a prior async
API.get completes; add a reentrancy guard (e.g., a local ref like isPollingRef
or inFlight boolean) and early-return if a poll is already in progress, ensure
you set isPollingRef.current = true before awaiting API.get and set it back to
false in a finally block; also when you detect terminal states ('success',
'failed', 'expired') clear the pollTimerRef and set isPollingRef.current = false
before calling Toast.success, showError, or onPaid to avoid duplicate callbacks;
update references to pollStatus, pollTimerRef, expireAtRef, markExpired, onPaid,
Toast.success, and showError accordingly.
背景
在
new-api中新增 Jeepay 充值支付接入,整体复用现有top_up充值链路,以最少改动完成 Jeepay 支付能力集成。本次改动
1. 支付下单能力
2. 异步通知处理
3. 支付方式支持
QR_CASHIER- 聚合扫码WEB_CASHIER- 收银台WX_NATIVE- 微信扫码ALI_QR- 支付宝扫码4. 订单查询与超时控制
5. 前后端相关调整
相比 #4098 的改进
针对上次 review 反馈的 6 个问题已全部修复:
weixin://...)已验证流程
测试
go test ./controller/ -run TestJeepay -v全部通过Summary by CodeRabbit
New Features
Tests