Nuwax Pay Skill
Overview
This skill (@nuwax-pay) provides REST endpoints to integrate payment into your projects. The frontend must pass projectId (sourced from the DEV_PROJECT_ID env var — see Frontend pitfalls for how to get it into the browser) plus the order details and amount. No login/tenant info is needed in the request body — the browser session identifies the tenant.
Use this skill when the user's project needs to collect money. Pick the integration mode by client context (pitfall #7), then use status query to confirm payment:
| Mode | When to use | Client | Effort |
|---|---|---|---|
Cashier mode (/api/pay/general/cashier) |
Default choice. Hosted cashier page. iframe dev: window.open + poll in place. prod: location.href + return URL. |
Any (App / browser / desktop) | Lowest — one API call + open cashier. |
App native mode (/app/create-order → /app/pay) |
Only inside App WebView. WxPay: redirectUrl + launchMiniProgram; AliPay: open redirectUrl. Poll /status. |
App WebView only — backend rejects non-App callers (9384) |
Medium — two API calls + invokeAppPay. |
H5 mode (/h5/create-order → /h5/pay) |
Custom payment UI in mobile/system browser (Safari, Chrome, WeChat built-in browser is separate). Two-step: create order, then invoke channel. | Mobile/system browser only — never in App WebView (backend rejects with 9383) |
Medium — two API calls + handle formHtml/redirectUrl. |
Status query (/api/pay/general/status) |
After payment, poll the order status (all modes). | Any | One API call. |
Client routing (MANDATORY — pitfall #7): App WebView → App API only (
/app/*). Phone/system browser → H5 API only (/h5/*) or cashier. Never call H5 from App or App from browser — the backend enforces this.
Order numbers: the backend generates business order numbers based on
projectId(required, fromDEV_PROJECT_ID):
- If you pass
bizOrderNo:GP1_{projectId}_{bizOrderNo}— idempotent, safe for retries.- If you omit
bizOrderNo:GP2_{projectId}_{timestamp}_{random}— auto-generated, always unique.
Payment is asynchronous — status polling is mandatory. The user pays on the cashier/channel page, which is out of your control. When the browser returns to your result page (via
frontNotifyUrl), the payment result may not be in the backend yet (and the user may not return at all). You MUST pollPOST /api/pay/general/statuson the return page untilstatus === "PAID"(orFAILED/CLOSED). Polling/statusis the only reliable source of truth for payment success — never trust that "the user came back" means "paid".
Return URL behavior by mode
| Context | Open cashier | Wait for result | frontNotifyUrl role |
|---|---|---|---|
Dev iframe preview (window.self !== window.top) |
window.open(cashierUrl) — never location.href in iframe |
Stay on pay page, poll /status until terminal; show result in place |
Passed to API for the popup window's post-pay redirect only — the iframe page does not rely on this URL coming back |
| Standalone / published prod | window.location.href = cashierUrl |
Cashier redirects to frontNotifyUrl → result page polls /status |
Required — drives the return-redirect flow (pitfall #2) |
This split is mandatory (pitfall #6). Using
location.hrefinside a dev iframe causes cross-origin cashier round-trips → blank page / lost order. The iframe path avoids leaving the iframe entirely.
Both modes end with gatewayOrderNo from storage, not from the return URL (pitfall #3). H5 relay behavior unchanged — see H5 section for iframe vs standalone channel launch.
🚨 Implementation iron rules — read these BEFORE writing a single line of payment code
These are the seven things that, when skipped or implemented loosely, are responsible for every "支付成功后跳转空白页 / 订单丢失 / 重复写入 / App 内 H5 违规" bug this skill has seen. Treat them as non-negotiable. Do NOT try to be clever and shortcut them.
projectIdfirst, verified by grep. Create.envwithDEV_PROJECT_ID→loadEnv+define __APP_PROJECT_ID__invite.config.ts→pnpm build && grep -c "$DEV_PROJECT_ID" dist/assets/*.jsmust return ≥ 1. A green build with emptyprojectIdis NOT done. (pitfall #1)frontNotifyUrlcarries NO#and NOgatewayOrderNo. Justorigin + pathname + '?from=pay-result'. Do not "helpfully" append the order number to the URL — see the FAQ in pitfall #2 for why that is a trap. (pitfall #2)- Guard rewrites the return URL in MODULE SCOPE, before the router is constructed — never in
useEffect/onMounted. (pitfall #2) - Recover
gatewayOrderNoas an OBJECT from storage, not as a field. The single most common self-inflicted bug islet s = obj?.gatewayOrderNo ?? fallback()— the??collapsessto a string ands?.gatewayOrderNois then foreverundefined→ blank result page. Copy the ❌/✅ block in pitfall #3 verbatim. (pitfall #3) - Two-path checkout (MANDATORY). Dev iframe preview →
window.open(cashierUrl)+ poll/statuson the unchanged pay page, show result in place,clearPendingOrderon PAID. Standalone/prod →window.location.href = cashierUrl+ return viafrontNotifyUrl. Neverlocation.hrefto cashier inside an iframe. (pitfall #6) - Shape A self-check is a HARD GATE (standalone path). Simulate
?from=pay-resultwith order pre-stashed — see Step 10 Path B. Iframe path self-check: click Pay in dev preview → popup opens → pay page polls/statusand shows result without iframe navigation. (Step 10) - App vs H5 routing (MANDATORY). Detect App WebView (
X-Client-Typeor UA) → call/app/create-order+/app/payonly. Mobile/system browser →/h5/*or cashier only. All pay API calls must usepayFetchsoX-Client-Typematches backend guards (9383/9384). (pitfall #7)
If you skip any one of these, expect a blank page, a lost/dupe record, or a channel violation in production. The pitfalls below explain the why; this block is the what to do. Do not proceed to writing UI until you can tick all seven.
Frontend pitfalls — READ FIRST
These are the five integration traps that cause real production bugs. Read this section before writing any payment code. Each one was hit and fixed in a real project; skipping them produces silent failures (callback page never opens, duplicate records, blank page on /prod).
1. projectId must be injected at build time — and you MUST create a .env file first
⚠️ This is the #1 most common failure. If you skip the
.envfile creation below,projectIdwill be an empty string in the browser, and the payment API will reject every request with"projectId must not be blank".
There are two layers to understand:
Layer 1 — Browser cannot read env vars. process.env.DEV_PROJECT_ID works in Node/agent scripts, not in frontend JS running in the user's browser. Vite only statically replaces specific import.meta.env.VITE_* / defined vars at build time; an arbitrary process.env.DEV_PROJECT_ID resolves to undefined (empty string) in the browser → empty projectId → API rejects the order.
This differs from skills that run as agent-side scripts (e.g. datatable-for-page-api, which reads os.environ.get('DEV_PROJECT_ID') in a Python script and works fine). For frontend browser code you must inject it at build time.
Layer 2 — Platform Vite process does NOT inherit the agent's env vars. The agent shell has DEV_PROJECT_ID set. But the platform starts the Vite dev server / build as a separate child process that does not inherit this variable. So process.env.DEV_PROJECT_ID is undefined inside the Vite process. The only reliable data source is a .env file in the project root, which Vite's loadEnv() reads directly from disk.
Step-by-step (MUST follow all 3 steps):
Step 1 — Create .env file (MANDATORY, do this FIRST):
The agent MUST create a .env file in the project root, writing the value from the agent's own $DEV_PROJECT_ID:
# Read from agent shell env and write to project .env
echo "DEV_PROJECT_ID=$DEV_PROJECT_ID" > .env
Without this file,
loadEnv()finds nothing andprocess.envin the Vite process is empty — the||fallback chain below collapses to''. This is not optional. If.envis missing, payment will silently fail.
Step 2 — Configure vite.config.ts to inject via define:
// vite.config.ts
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
// loadEnv reads from .env / .env.{mode} files in cwd.
// NOTE: loadEnv does NOT read process.env — the process.env fallback below
// is a secondary safety net, but the .env file (Step 1) is the primary source.
const env = loadEnv(mode, process.cwd(), '');
const projectId =
env.DEV_PROJECT_ID ||
process.env.DEV_PROJECT_ID || // secondary fallback — usually empty in platform Vite process
''; // do NOT hardcode here — keep the literal in the project, not the skill
return {
base: './', // see pitfall #4
define: {
__APP_PROJECT_ID__: JSON.stringify(projectId),
},
// ...plugins, etc.
};
});
Step 3 — Verify projectId was actually injected (MANDATORY gate):
After configuring, the agent MUST verify the value made it into the build output. This is a hard success criterion — if verification fails, the integration is NOT complete.
# Build the project
pnpm build # or the project's build command
# Verify projectId is embedded in the output (must return >= 1)
grep -c "$DEV_PROJECT_ID" dist/assets/*.js
# If it returns 0, projectId injection FAILED — go back and check:
# 1. Does .env exist and contain DEV_PROJECT_ID=<non-empty>? (cat .env)
# 2. Does vite.config.ts call loadEnv and define __APP_PROJECT_ID__?
# 3. Re-run build and re-verify.
⚠️ Do NOT skip Step 3. A build that compiles successfully can still have an empty
projectId(thedefinehappily replaces with""). Thegrepcheck is the only way to catch this before runtime.
Then in frontend code, read the constant:
// pay.ts
declare const __APP_PROJECT_ID__: string;
export const PROJECT_ID = typeof __APP_PROJECT_ID__ !== 'undefined' ? __APP_PROJECT_ID__ : '';
// send `projectId: PROJECT_ID` in cashier / create-order request bodies (NOT in /status)
Do NOT write
projectId: process.env.DEV_PROJECT_IDin frontend fetch bodies. Do NOT useimport.meta.env.DEV_PROJECT_IDunless you have explicitly prefixed your env var asVITE_DEV_PROJECT_IDand exposed it. Thedefine+ global constant pattern above is the reliable one.
2. frontNotifyUrl MUST NOT contain a # hash fragment — and the return guard MUST run BEFORE the router reads the hash
⚠️ Neither the cashier nor the H5 backend relay appends payment params to your final URL.
- Cashier mode: verified against the hosted cashier source —
redirectToMerchantAfterPaid()doeswindow.location.href = ctx.bizRedirectUrlusing yourfrontNotifyUrl *verbatim*. It does NOT appendorderNo,gatewayOrderNo,status, or any other payment param.- H5 mode: WeChat/Alipay redirect to
/api/pay/general/h5/front-notify?returnUrl=...first; that endpoint validatesreturnUrland 302s to yourfrontNotifyUrlas-is — it does NOT append payment params either. Any channel query params on the relay request are discarded.Earlier versions of this skill assumed the channel appends payment params on return — that assumption is WRONG and is the root cause of "lost order / blank page" bugs. The only thing the browser sees on the result page is exactly the
frontNotifyUrlyou set (e.g.https://host/page/{id}/?from=pay-result). Plan your recovery strategy around that fact: the order handle must come from YOUR storage, not from the return URL.
Most SPA projects on this platform use hash routing (/#/some-page). You still MUST NOT put a # in frontNotifyUrl: createHashRouter reads window.location.hash at construction time, and a return URL that carries no hash (which it never does — you build frontNotifyUrl without one) makes the router force in a #/ and boot on the wrong route → blank page. The job of the return guard below is to rewrite that no-hash URL into #/pay-result before the router reads it.
Rule: frontNotifyUrl should be a plain URL without a hash, carrying only a query flag (e.g. ?from=pay-result). Then a top-level return guard rewrites the URL into the hash route.
// On the pay/checkout page — build the return URL WITHOUT a hash
const frontNotifyUrl =
window.location.origin + window.location.pathname + '?from=pay-result';
⚠️ CRITICAL — timing: the guard MUST run BEFORE the hash router is created, not in a
useEffect/onMounted.This is the single most common cause of the blank return page. The guard's job is to fix
window.location.hashso the router boots on the right route. But:
- A hash router (
createHashRouter/createRouter({ history: createWebHashHistory() })) reads and initializeswindow.location.hashat the moment it is constructed. If the URL has no hash yet, the router forces one in (usually#/) and boots from there.useEffect(React) andonMounted(Vue) run after the component mounts — by which point the router module has already been imported and constructed. The router has already read the empty/wrong hash. Rewriting the URL afterwards is too late:history.replaceStatedoes not fire ahashchange, so the router never re-parses → it stays on the wrong route → blank page.Therefore the rewrite must happen synchronously, in module scope, before the router is constructed (an IIFE that runs at module-eval time, before the
createHashRouter(...)call on the next lines). Do NOT put it in a hook/effect. Do NOT import a pre-builtrouterobject and then try to fix the URL inApp's body — the import already constructed it.
// App.tsx (React, hash router) — rewrite in MODULE SCOPE, before createHashRouter.
import { createHashRouter, RouterProvider } from 'react-router-dom';
import { ROUTES } from './router'; // plain ROUTES array, NOT a pre-built router
const PAY_FLAG = 'from';
const PAY_FLAG_VALUE = 'pay-result';
const PAY_PARAMS = ['orderNo', 'gatewayOrderNo', 'status', 'payChannel', 'paidAt'];
// Does a param set look like a payment return? Carries a pay param OR the flag.
// Do NOT depend solely on the `from=pay-result` flag — a channel/proxy may strip
// or alter query params. Either signal, in search OR hash query, counts.
function isPayReturn(sp: URLSearchParams): boolean {
if (sp.get(PAY_FLAG) === PAY_FLAG_VALUE) return true;
return PAY_PARAMS.some((k) => !!sp.get(k));
}
// Runs synchronously at module load — BEFORE createHashRouter reads the hash.
(function normalizePayReturnUrl() {
const url = new URL(window.location.href);
const hashSp = new URLSearchParams(url.hash.split('?')[1] ?? '');
// Find which location carries the payment-return payload (search or hash query).
const source =
isPayReturn(url.searchParams) ? url.searchParams
: isPayReturn(hashSp) ? hashSp
: null;
if (!source) return; // not a payment return — nothing to do
// Carry any payment params over (defensive — cashier/H5 relay append NONE today,
// but a future channel might; keep them in the hash query if present).
const pairs: string[] = [];
for (const key of PAY_PARAMS) {
const v = source.get(key);
if (v) pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`);
}
source.forEach((value, key) => {
if (key === PAY_FLAG || PAY_PARAMS.includes(key)) return;
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
});
url.search = '';
url.hash = `#/pay-result${pairs.length ? `?${pairs.join('&')}` : ''}`;
// replaceState: no new history entry, no reload — router now sees the clean URL.
window.history.replaceState(null, '', url.toString());
})();
// Constructed AFTER the rewrite above — so it reads the corrected hash.
const router = createHashRouter(ROUTES);
export default function App() {
return <RouterProvider router={router} />;
}
Why
replaceStateand notwindow.location.replace(...)?replace()triggers a full navigation/reload that races with module init and re-runs the IIFE.replaceStatemutates the URL in place with no reload and nohashchange— exactly what we want, because the router (constructed on the very next line) then reads the already-correct hash.
// Vue 3 equivalent — in main.ts BEFORE createApp/use(router), NOT in App.vue onMounted.
import { createApp } from 'vue';
import { createRouter, createWebHashHistory } from 'vue-router';
import App from './App.vue';
const PAY_FLAG = 'from';
const PAY_FLAG_VALUE = 'pay-result';
const PAY_PARAMS = ['orderNo', 'gatewayOrderNo', 'status', 'payChannel', 'paidAt'];
function isPayReturn(sp) {
if (sp.get(PAY_FLAG) === PAY_FLAG_VALUE) return true;
return PAY_PARAMS.some((k) => !!sp.get(k));
}
;(function normalizePayReturnUrl() {
const url = new URL(window.location.href)
const hashSp = new URLSearchParams(url.hash.split('?')[1] ?? '')
const source = isPayReturn(url.searchParams) ? url.searchParams
: isPayReturn(hashSp) ? hashSp : null
if (!source) return
const pairs = []
for (const key of PAY_PARAMS) {
const v = source.get(key)
if (v) pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`)
}
source.forEach((value, key) => {
if (key === PAY_FLAG || PAY_PARAMS.includes(key)) return
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
})
url.search = ''
url.hash = `#/pay-result${pairs.length ? `?${pairs.join('&')}` : ''}`
window.history.replaceState(null, '', url.toString())
})()
const router = createRouter({ history: createWebHashHistory(), routes: [/* ... */] });
createApp(App).use(router).mount('#app');
For path-routed SPAs (BrowserRouter /
createWebHistory),frontNotifyUrlcan be the path directly (origin + '/pay-result') — no guard needed. Hash routing is the case that needs the flag + pre-router guard.
❓ FAQ — "Since the return URL never has the order number, why don't I just append
gatewayOrderNotofrontNotifyUrlmyself so it survives the redirect?"You can build
frontNotifyUrl = ... + '?from=pay-result&gatewayOrderNo=' + gw, but do not — it is unnecessary and harmful. The reliable recovery source forgatewayOrderNois the blob you wrote tolocalStorage(pendingOrder:{gatewayOrderNo}) before the redirect, which is far more durable than a URL query (URLs get rewritten/stripped by proxies, channels, private mode;localStorageonly disappears if the user actively clears cache). Putting the order number in the URL is therefore only a redundant copy of storage, not a replacement, and it adds two real risks:
- Exposure — the gateway order number sits in the address bar, viewable/screenshotable/editable by the user.
- Idempotency-key poisoning — an attacker can hand-craft
?gatewayOrderNo=<someone else's order>to try to trip thepaid:{...}write. (/statustruth-checks the real payment state, so this is contained, but it is needless attack surface.)Conclusion:
frontNotifyUrlcarries ONLY the?from=pay-resultflag. The order handle comes from storage. This is intentional design — do not "helpfully" also put the order number in the URL. (The guard'sPAY_PARAMSscan still picks up a param if a channel ever echoes one — that path is defensive-only, not something you build the URL around.)
3. Recover gatewayOrderNo from storage FIRST — the return URL does NOT echo it back
⚠️ Read this together with pitfall #2. Because both cashier and H5 relay return to
frontNotifyUrlas-is (no params appended), the result URL normally contains nogatewayOrderNoat all. SogatewayOrderNoMUST be recovered primarily from storage you wrote before leaving for payment — the URL-query fallback is only a defensive extra for the day some channel does echo a param. Do not design the recovery around "the URL will have the order number" — it usually will not.
🛠 Two independent chains — both must work. Do not assume that because the guard (pitfall #2) landed the URL on
#/pay-result, the result page is fine. The guard only fixes routing (URL → route match). Recovering the data (gatewayOrderNo→ able to poll/statusand write the record) is a separate chain (this pitfall). A paid order can land on the right route but still show "未找到订单 / blank" if this recovery chain is broken. The Shape A self-check (Step 10) verifies BOTH at once — that is why it is mandatory.
⚠️ THE most common self-inflicted bug — keep
stashedas the OBJECT, read the field only at the very end. The recovery variable holds the parsed object ({ gatewayOrderNo, amount, ... }). If you writelet s = obj?.gatewayOrderNo ?? fallback(), then when sessionStorage hits, the??collapsessto the order-number string, and every subsequents?.gatewayOrderNois foreverundefined→ recovery fails → paid order lands on a "未找到订单 / blank" page. Copy this block verbatim:
// ❌ WRONG — stashed collapses to a STRING (the order number), so the later
// stashed?.gatewayOrderNo is ALWAYS undefined → blank result page.
let stashed = JSON.parse(sessionStorage.getItem('pendingOrder') || 'null');
stashed = stashed?.gatewayOrderNo ?? recoverPendingFromLocalStorage(); // BUG
const gatewayOrderNo = stashed?.gatewayOrderNo || /* url */ ''; // always '' here
// ✅ RIGHT — keep stashed as the OBJECT; only read the field at the very end.
let stashed = JSON.parse(sessionStorage.getItem('pendingOrder') || 'null');
if (!stashed || typeof stashed !== 'object' || !stashed.gatewayOrderNo) {
stashed = recoverPendingFromLocalStorage(); // localStorage OBJECT, not a field
}
const gatewayOrderNo =
stashed?.gatewayOrderNo || // field read ONCE, last
hq.get('gatewayOrderNo') || q.get('gatewayOrderNo') || // URL defensive fallback
hq.get('orderNo') || q.get('orderNo') || '';
There are three recovery sources, tried in this order:
sessionStorage(primary) — stashpendingOrder({ gatewayOrderNo, amount, name, ... }) on the pay page before redirecting to the cashier. Same-origin, survives the redirect within the same tab.localStorage(MANDATORY cross-origin fallback) —sessionStorageis not reliable across payment redirects: the cashier is a different origin (m10096.nuwax.com), and H5 channel pages are also outside your app. Some browsers / privacy modes / "return in a new tab" scenarios drop or isolate session storage. You MUST also persist the same pending-order blob tolocalStorageunder a key that includesgatewayOrderNo(e.g.pendingOrder:{gatewayOrderNo}) on the pay page before redirecting to the cashier or invoking H5 pay. The result page scans those keys and recovers the most-recent one when sessionStorage is gone. Without this, a paid order returns to a "未找到订单 / failed" screen in exactly the cases that matter most (cross-origin, new tab). Clear the entry after a successful PAID write to avoid unbounded growth.URL query (defensive fallback only) — scan
location.searchAND the hash query forgatewayOrderNo/orderNo. Usually empty (cashier and H5 relay append nothing), but harmless to check. AdduseSearchParams()from your router as one more source if available.
// On the PAY/checkout page — stash BOTH sessionStorage AND localStorage before redirect.
const pending = { gatewayOrderNo, amountInFen, name, message };
const pendingJson = JSON.stringify(pending);
sessionStorage.setItem('pendingOrder', pendingJson); // primary
localStorage.setItem(`pendingOrder:${gatewayOrderNo}`, pendingJson); // cross-origin fallback
// On the RESULT page — try every source so a paid order is never lost.
function resolveGatewayOrderNo() {
// 1. sessionStorage
const stashed = JSON.parse(sessionStorage.getItem('pendingOrder') || 'null');
if (stashed?.gatewayOrderNo) return stashed.gatewayOrderNo;
// 2. localStorage fallback (scan all pendingOrder:* keys, freshest wins)
const lsHit = recoverPendingFromLocalStorage();
if (lsHit?.gatewayOrderNo) return lsHit.gatewayOrderNo;
// 3. URL query — defensive (cashier usually appends nothing; pitfall #2)
const q = new URLSearchParams(window.location.search);
const hq = new URLSearchParams((window.location.hash.split('?')[1]) || '');
return hq.get('gatewayOrderNo') || q.get('gatewayOrderNo')
|| hq.get('orderNo') || q.get('orderNo') || '';
}
function recoverPendingFromLocalStorage() {
const entries = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key || !key.startsWith('pendingOrder:')) continue;
try {
const o = JSON.parse(localStorage.getItem(key) || 'null');
if (o?.gatewayOrderNo) entries.push(o);
} catch { /* ignore malformed */ }
}
return entries.length ? entries[entries.length - 1] : null; // last written = most recent
}
4. Sub-path deployment: keep /api/* absolute, set Vite base: './'
Projects deploy under a sub-path (dev: /page/{projectId}-xxxx/dev/, prod: /page/{projectId}-xxxx/prod/). Two rules that are easy to get backwards:
- Static assets (JS/CSS) → use a relative base. Set
base: './'invite.config.tsso the bundle references./assets/.... With the defaultbase: '/', assets resolve to the domain root under a sub-path → 404 → blank page (the result page's JS won't even load). - API calls
/api/pay/general/*→ MUST stay absolute (/api/...). The API gateway is mounted at the domain root/api/, independent of the project's sub-path. Do not "relativize" the API path together with assets.
frontNotifyUrl should use window.location.origin + window.location.pathname (read at runtime) so it automatically follows whatever sub-path (/dev/ or /prod/) the user is actually on — never hardcode /dev or /prod.
5. Persisting a business record after PAID MUST be idempotent (by gatewayOrderNo)
The status poll fires onPaid once you hit PAID, but your business write (creating an order row, recording a tip, etc.) can still execute multiple times because:
- React
<StrictMode>runsuseEffecttwice in dev (mount → unmount → mount) — an in-memorylet done = falseflag resets on the second run → duplicate records. - The user refreshes the result page; the flag is gone.
Always gate the business write with a persisted idempotency key keyed by gatewayOrderNo (localStorage/sessionStorage), not an in-memory variable. Reserve the slot before writing, roll it back if the write fails.
Also: never silently SKIP the write when sessionStorage is missing — fall back to the pay status. The onPaid callback receives info from /status, which contains orderAmount (and paidAt, payChannel). If the stashed pendingOrder is gone (new tab, refresh after close, private mode), the order is still paid and gatewayOrderNo was recovered from the URL (pitfall #3) — so you still have enough to write the record. Recover amount from info.orderAmount and use a sensible default (e.g. an "anonymous" name) for any business field you can't recover. The bug to avoid: wrapping the write in if (stashed) {...} so a missing stash turns a successful payment into a lost record.
const KEY = `paid:${gatewayOrderNo}`;
async function onPaid(info) {
if (localStorage.getItem(KEY) === '1') { setState('paid'); return; }
localStorage.setItem(KEY, '1'); // reserve first
try {
// stashed may be missing — fall back to the status payload so a paid order
// is NEVER turned into a lost record. Never gate the whole write on stashed.
const stashed = JSON.parse(sessionStorage.getItem('pendingOrder') || 'null');
await createBusinessRecord({
gatewayOrderNo,
amount: stashed?.amount ?? info.orderAmount ?? 0, // status payload fallback
name: stashed?.name ?? 'anonymous',
message: stashed?.message ?? '',
// ...any other fields, each with a fallback
});
sessionStorage.removeItem('pendingOrder');
localStorage.removeItem(`pendingOrder:${gatewayOrderNo}`); // pitfall #3 fallback cleanup
} catch (e) {
localStorage.removeItem(KEY); // roll back so it can retry
console.error(e);
// surface it — a silent failure here means a paid order with no record
setState('paid'); setStatus('支付成功,但记录同步失败,请联系商家');
return;
}
setState('paid');
}
⚠️ Persist
gatewayOrderNointo your BUSINESS DATA TABLE — not just browser storage. Browser storage (sessionStorage/localStorage) is only the recovery mechanism for the post-return page; it is NOT a record of the payment. The user clears their cache, switches devices, or a support agent looks up an order → that data is gone. The business record you write onPAIDMUST includegatewayOrderNoas a dedicated column in your data table (e.g. adonations/orderstable with agateway_order_nocolumn). This is the single source of truth for traceability — later you can resync any order by passing its storedgatewayOrderNoback into/status.Write is half the job — expose the read side too. A column you INSERT but never SELECT is invisible: you can't show it, can't search by it, can't reconcile against it. When you design the data table and its SQL APIs (via the
datatable-for-page-apiskill), make sure EVERY read API (list/get/search)SELECTsgateway_order_noalongside the business fields, and the frontend model carriesgatewayOrderNoend-to-end. The common bug:addwrites the column, butgetAll/searchforget to SELECT it → the gateway order number is stored but unreachable for display or dispute resolution.Why bother if you don't render it in the UI? Because the moment a customer says "I paid but it's not in the wall",
gatewayOrderNois the only handle that lets you look the payment up in/status, prove it paid, and decide whether to manually repair the record. NogatewayOrderNoin the table = no way to investigate. Always persist it, and always be able to read it back.
6. Dev iframe preview: window.open + in-place poll (never location.href)
⚠️ Mandatory for dev preview. Platform dev embeds your project in an iframe. If you
window.location.href = cashierUrlinside the iframe, the iframe navigates cross-origin to the cashier and back — causing blank pages, lost storage, and broken layouts. The only supported dev-iframe pattern is:
系统预览壳(外层,不变)
└── iframe(项目页,始终不离开)
点支付 → stashPendingOrder → window.open(收银台) ← 新窗口
原页轮询 POST /api/pay/general/status
PAID → 原页展示结果 + clearPendingOrder
Standalone / published prod (window.self === window.top):
项目页 → stashPendingOrder → location.href = 收银台
收银台付完 → frontNotifyUrl 回跳 → 结果页轮询 /status
Rules (non-negotiable):
| Step | iframe dev preview | standalone / prod |
|---|---|---|
| Before pay | stashPendingOrder(...) |
stashPendingOrder(...) |
| Open cashier | window.open(cashierUrl, '_blank', 'noopener,noreferrer') |
window.location.href = cashierUrl |
| Wait for result | Poll /status on pay page (same component or inline UI) |
Cashier returns to frontNotifyUrl → result page polls |
| On PAID | Show success in place + clearPendingOrder(gatewayOrderNo) + idempotent business write |
Result page flow (pitfall #2–#5) + cleanup stash |
frontNotifyUrl |
Still pass to /cashier API (popup redirects there after pay — iframe ignores it) |
Drives return redirect (no #, pitfall #2) |
Copy-paste helpers:
export function isEmbeddedPreview() {
try { return window.self !== window.top; } catch { return true; }
}
export function buildFrontNotifyUrl() {
return window.location.origin + window.location.pathname + '?from=pay-result';
}
const PENDING_LATEST_KEY = 'pendingOrder:latest';
export function stashPendingOrder(pending) {
const json = JSON.stringify(pending);
const gw = pending.gatewayOrderNo;
localStorage.setItem(`pendingOrder:${gw}`, json);
localStorage.setItem(PENDING_LATEST_KEY, json);
try { sessionStorage.setItem('pendingOrder', json); } catch { /* ignore */ }
}
export function clearPendingOrder(gatewayOrderNo) {
try { sessionStorage.removeItem('pendingOrder'); } catch { /* ignore */ }
localStorage.removeItem(`pendingOrder:${gatewayOrderNo}`);
localStorage.removeItem(PENDING_LATEST_KEY);
}
/** iframe: window.open; standalone: same-window redirect */
export function openCashier(cashierUrl) {
if (isEmbeddedPreview()) {
const w = window.open(cashierUrl, '_blank', 'noopener,noreferrer');
if (!w) throw new Error('弹窗被拦截,请允许弹出窗口后重试');
return w;
}
window.location.href = cashierUrl;
return null;
}
/**
* iframe path: after openCashier, call this on the pay page.
* Shows result via callbacks — no URL return needed.
*/
export function pollUntilPaid(gatewayOrderNo, { onPaid, onFailed, intervalMs = 2500, timeoutMs = 60 * 60 * 1000 } = {}) {
let stop = false;
const start = Date.now();
(async () => {
while (!stop && Date.now() - start < timeoutMs) {
try {
const info = await queryStatus(gatewayOrderNo);
if (info.status === 'PAID') { onPaid?.(info); return; }
if (info.status === 'FAILED' || info.status === 'CLOSED') { onFailed?.(info); return; }
} catch { /* keep polling */ }
await new Promise((r) => setTimeout(r, intervalMs));
}
if (!stop) onFailed?.({ status: 'CLOSED' });
})();
return () => { stop = true; };
}
iframe pay flow (cashier — copy verbatim):
async function payWithCashierInIframe({ amountInFen, subject, name, message, onPaid, onFailed }) {
const frontNotifyUrl = buildFrontNotifyUrl(); // for popup return only
const { gatewayOrderNo, cashierUrl } = await createCashierOrder({ amountInFen, subject, frontNotifyUrl });
const pending = { gatewayOrderNo, amountInFen, subject, name, message };
stashPendingOrder(pending);
openCashier(cashierUrl); // window.open — iframe stays put
return pollUntilPaid(gatewayOrderNo, {
onPaid: async (info) => {
await writeBusinessRecordOnce(pending, info); // pitfall #5
clearPendingOrder(gatewayOrderNo);
onPaid?.(info);
},
onFailed,
});
}
standalone pay flow (cashier):
async function payWithCashierStandalone({ amountInFen, subject, name, message }) {
const frontNotifyUrl = buildFrontNotifyUrl();
const { gatewayOrderNo, cashierUrl } = await createCashierOrder({ amountInFen, subject, frontNotifyUrl });
stashPendingOrder({ gatewayOrderNo, amountInFen, subject, name, message });
window.location.href = cashierUrl; // leaves page — result handled on return URL
}
Unified entry (use this in PayButton):
export async function payWithCashier(opts) {
if (isEmbeddedPreview()) return payWithCashierInIframe(opts);
return payWithCashierStandalone(opts);
}
❌ Never do this in iframe dev preview:
window.location.href = cashierUrl— this is the #1 cause of post-pay blank iframe.Popup blocked? Show a clear error asking the user to allow popups. Do not fall back to
location.hrefinside iframe.
7. App WebView vs mobile browser — route to the correct API (never mix H5 and App)
⚠️ Channel policy: WeChat/Alipay H5 pay inside an App WebView is a violation — the backend rejects it with error
9383(pay_h5_not_allowed_in_app). Conversely, App native pay from a system browser is rejected with9384(pay_app_native_requires_app). Your frontend MUST pick the API set before calling create-order.⚠️ Backend reads the HTTP header
X-Client-Type, not a JS global alone. App 壳注入window.__NUWAX_CLIENT_TYPE__后,每次支付 API 请求都必须带上同名 Header(用下方payFetch)。仅改页面内变量、Header 未带 → 前端走/app/*后端判非 App(9384),或前端走/h5/*后端判 App(9383)。
Detection + fetch wrapper (copy into pay.ts):
export const PAY_HEADER_CLIENT_TYPE = 'X-Client-Type';
function isAppFromClientTypeValue(clientType: string): boolean {
const n = clientType.trim().toLowerCase();
if (['web', 'h5', 'browser', 'wap'].includes(n)) return false;
if (n.startsWith('app') || ['native', 'ios', 'android', 'mobile', 'mobile-app', 'nuwax-app'].includes(n)) return true;
return true; // 非 web 类取值 → App 壳(与后端 PayAppWebViewDetector 一致)
}
function isAppFromUserAgent(): boolean {
const ua = navigator.userAgent || '';
if (/NuwaxApp|NUWAX_APP|nuwax-app/i.test(ua)) return true;
if (/nuwax/i.test(ua) && (/webview|;\s*wv\)/i.test(ua))) return true;
return false;
}
/** App 壳注入:ios | android | app 等;纯浏览器返回 undefined */
export function resolveClientTypeHeader(): string | undefined {
const fromBridge = (window as any).__NUWAX_CLIENT_TYPE__ as string | undefined;
if (fromBridge?.trim()) return fromBridge.trim();
if (isAppFromUserAgent()) return 'app';
return undefined;
}
/** 前端路由:与后端 9383/9384 守卫对齐 */
export function isAppWebView(): boolean {
const ct = resolveClientTypeHeader();
if (ct) return isAppFromClientTypeValue(ct);
return isAppFromUserAgent();
}
/** 所有支付 API 必须经此发起 — 自动附带 X-Client-Type */
export function payFetch(input: RequestInfo, init: RequestInit = {}) {
const headers = new Headers(init.headers);
if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
const clientType = resolveClientTypeHeader();
if (clientType) headers.set(PAY_HEADER_CLIENT_TYPE, clientType);
return fetch(input, { ...init, headers });
}
/** Pick pay path before create-order */
export function resolvePayApiSet(): 'app' | 'h5' | 'cashier' {
if (isAppWebView()) return 'app';
return 'h5'; // or 'cashier' if you don't need custom UI
}
| Client | APIs | After pay |
|---|---|---|
| App WebView | POST /api/pay/general/app/create-order → POST /api/pay/general/app/pay |
WxPay(当前渠道): redirectUrl(weixin://...)→ wx.miniapp.launchMiniProgram;AliPay: 打开 redirectUrl;poll /status |
| Mobile/system browser (custom UI) | POST /api/pay/general/h5/create-order → POST /api/pay/general/h5/pay |
Same two-path rule as pitfall #6 (iframe popup vs standalone return) |
| Any (default) | POST /api/pay/general/cashier |
Cashier two-path rule (pitfall #6) |
App native invoke (WebView only — align with App 原生支付.md):
⚠️ 当前微信 App 渠道(安心付)不走
wxPayParams。 网关返回invokeType=REDIRECT_URL+redirectUrl(weixin://dl/business/...),前端须解析query=后的 Base64,用wx.miniapp.launchMiniProgram拉起小程序(userName=gh_cd6acad9a40d,path=ipay/main?{param})。不要对微信redirectUrl使用uni.requestPayment/ 直接打开链接。
/** 从网关 redirectUrl 解析 Param(query= 后的 Base64 段) */
function parseAnxinfuMiniProgramParam(redirectUrl) {
if (!redirectUrl) return '';
const q = redirectUrl.split('query=')[1]?.split('&')[0] ?? '';
return q; // 已是 Base64,勿 encodeURIComponent
}
function launchAnxinfuWxPay(redirectUrl) {
const param = parseAnxinfuMiniProgramParam(redirectUrl);
if (!param) return Promise.reject(new Error('无法从 redirectUrl 解析支付参数'));
return new Promise((resolve, reject) => {
wx.miniapp.launchMiniProgram({
userName: 'gh_cd6acad9a40d',
path: 'ipay/main?' + param,
miniprogramType: 0,
success: (res) => {
…(truncated)