Merge pull request 'fix(payment): 支付轮询链路收口——查单节流 + 前端轮询重写' (#7) from feat/site into main
deploy-132 / deploy (push) Successful in 3m21s

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-07-20 04:55:29 +00:00
10 changed files with 817 additions and 46 deletions
+325 -2
View File
@@ -10,6 +10,7 @@
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.25.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0",
@@ -19,6 +20,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
@@ -1961,6 +1963,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -2092,9 +2114,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -2281,6 +2301,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -2360,6 +2389,17 @@
"node": ">= 6"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2369,6 +2409,24 @@
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -2472,6 +2530,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -2506,6 +2573,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -2528,6 +2601,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
@@ -2680,6 +2759,19 @@
"node": ">=8"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -2729,6 +2821,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2817,6 +2918,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -3222,6 +3332,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3395,6 +3517,42 @@
"node": ">=12.20.0"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -3408,6 +3566,15 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -3462,6 +3629,15 @@
"node": ">= 6"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -3651,6 +3827,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3786,6 +3979,15 @@
"node": ">=8"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -3796,6 +3998,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -3968,6 +4176,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -4005,6 +4219,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -4305,6 +4545,13 @@
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -5117,6 +5364,12 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -5134,6 +5387,35 @@
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
@@ -5151,12 +5433,53 @@
"dev": true,
"license": "MIT"
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
}
}
}
+2
View File
@@ -13,6 +13,7 @@
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.25.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0",
@@ -22,6 +23,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
+32
View File
@@ -301,6 +301,38 @@ export async function adminReconcile(): Promise<{ diffs: ReconcileDiff[]; ok: bo
return { diffs: d.diffs ?? [], ok: !!d.ok };
}
// ---- 支付联调:走「用户面」的真实下单/查单接口 ----
// 刻意不做 admin 专用旁路:验的就是用户真实走的那条路(下单 → 渠道 → 回调/查单 → 入账),
// 造条测试专线的话验过了也不代表线上通。因此这两个打的是 /api/v1/billing/*(非 /admin/*),
// 租户由服务端按当前登录用户解析,无需带租户头。
export interface TestOrder {
order_id: string;
code_url: string;
amount_fen: number;
expires_at: string;
}
export async function createPayOrder(packId: string): Promise<TestOrder> {
const res = guard(
await fetch(`${GATEWAY}/api/v1/billing/orders`, {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify({ pack_id: packId }),
}),
);
const d = (await res.json().catch(() => ({}))) as Partial<TestOrder> & { error?: string };
if (!res.ok) throw new Error(d.error ?? `下单失败: ${res.status}`);
return d as TestOrder;
}
// warn:已付但金额与订单不符 —— 服务端不入账、挂起人工核对,订单会一直停在 pending。
export async function payOrderStatus(orderId: string): Promise<{ order: PayOrder; warn?: string }> {
const res = guard(await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { order?: PayOrder; warn?: string; error?: string };
if (!res.ok) throw new Error(d.error ?? `查询失败: ${res.status}`);
return { order: d.order as PayOrder, warn: d.warn };
}
// 人工退款:仅对已入账(paid)单——置 refunded + 记 adjust 负分录 + 回退余额(幂等)。
// status="noop" 表示该单本就无需退(已退/未支付)。真渠道钱的原路退回需 admin 另在商户后台操作。
export async function adminRefundOrder(id: string, memo: string): Promise<{ status: string; detail?: string }> {
@@ -0,0 +1,199 @@
import { useEffect, useRef, useState } from "react";
import QRCode from "qrcode";
import { createPayOrder, payOrderStatus, type CreditPack, type PayOrder } from "../api";
// 支付联调:在管理端就地下一笔**真单**并盯到终态,用来验证「渠道配置 → 下单 → 扫码付款
// → 回调/查单 → 入账」整条链路在当前环境真的通。
//
// 刻意走用户面的 /api/v1/billing/* 而非 admin 旁路:造条测试专线的话,验过了也不能
// 说明线上用户那条路通。代价是这会真扣钱 —— 所以 UI 上必须讲清楚,并给出退款去处。
//
// 轮询策略与 sundynix-web 的支付弹窗一致:递归 setTimeout(不让慢请求摞起来)、
// 切后台暂停(扫完码要去微信 App)、失败指数退避、倒计时以服务端 expires_at 为准。
const POLL_BASE_MS = 2500;
const POLL_MAX_MS = 15000;
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
type Phase = "creating" | "waiting" | "paid" | "expired";
export function PayProbeDialog({ pack, onClose }: { pack: CreditPack; onClose: (paid: boolean) => void }) {
const [qr, setQr] = useState("");
const [err, setErr] = useState("");
const [warn, setWarn] = useState("");
const [phase, setPhase] = useState<Phase>("creating");
const [order, setOrder] = useState<PayOrder | null>(null);
const [left, setLeft] = useState(0);
const [polls, setPolls] = useState(0); // 轮询次数:链路是否在动,一眼可见
const orderRef = useRef("");
const expiresRef = useRef(0);
useEffect(() => {
let alive = true;
let timer: number | null = null;
let failures = 0;
const stop = () => {
if (timer) window.clearTimeout(timer);
timer = null;
};
const schedule = (ms: number) => {
stop();
timer = window.setTimeout(() => void tick(), ms);
};
const tick = async () => {
if (!alive) return;
if (document.hidden) return schedule(POLL_BASE_MS);
try {
const { order: o, warn: w } = await payOrderStatus(orderRef.current);
if (!alive) return;
failures = 0;
setPolls((n) => n + 1);
setOrder(o);
setWarn(w ?? "");
if (o.status === "paid") {
setPhase("paid");
stop();
return;
}
if (o.status === "expired" || o.status === "failed") {
setPhase("expired");
stop();
return;
}
schedule(POLL_BASE_MS);
} catch (e) {
if (!alive) return;
failures += 1;
setErr((e as Error).message);
schedule(Math.min(POLL_BASE_MS * 2 ** failures, POLL_MAX_MS));
}
};
const onVisible = () => {
if (!document.hidden && alive && orderRef.current) schedule(0);
};
document.addEventListener("visibilitychange", onVisible);
void (async () => {
try {
const o = await createPayOrder(pack.id);
if (!alive) return;
orderRef.current = o.order_id;
expiresRef.current = new Date(o.expires_at).getTime();
setLeft(Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000)));
setQr(await QRCode.toDataURL(o.code_url, { width: 220, margin: 1 }));
setPhase("waiting");
schedule(POLL_BASE_MS);
} catch (e) {
if (alive) setErr((e as Error).message);
}
})();
return () => {
alive = false;
stop();
document.removeEventListener("visibilitychange", onVisible);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pack.id]);
useEffect(() => {
if (phase !== "waiting") return;
const t = window.setInterval(() => {
const s = Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000));
setLeft(s);
if (s === 0) setPhase("expired");
}, 1000);
return () => window.clearInterval(t);
}, [phase]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/40 p-4">
<div className="w-full max-w-md rounded-xl bg-white p-5 shadow-xl">
<div className="flex items-baseline justify-between">
<h4 className="text-sm font-semibold text-gray-800"> · {pack.name}</h4>
<button onClick={() => onClose(phase === "paid")} className="text-xs text-gray-400 hover:text-gray-600">
</button>
</div>
<p className="mt-1 rounded-md bg-amber-50 px-2.5 py-1.5 text-[11px] leading-relaxed text-amber-700">
<b></b> · 退
</p>
<div className="mt-4 flex flex-col items-center gap-3">
{err && !qr ? (
<p className="py-10 text-center text-xs text-rose-500">{err}</p>
) : phase === "creating" ? (
<p className="py-16 text-xs text-gray-400"></p>
) : (
<>
<div className="relative rounded-lg border border-gray-100 bg-white p-2">
<img src={qr} alt="微信支付二维码" width={220} height={220} />
{(phase === "expired" || phase === "paid") && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-white/85 text-sm font-medium text-gray-700">
{phase === "paid" ? "✅ 已入账" : "二维码已失效"}
</div>
)}
</div>
<div className="text-center">
<div className="text-lg font-semibold tabular-nums text-gray-800">¥{(pack.price_fen / 100).toFixed(2)}</div>
{phase === "waiting" && <div className="mt-0.5 text-[11px] tabular-nums text-gray-400"> {mmss(left)} </div>}
</div>
</>
)}
{warn && (
<p className="w-full rounded-md bg-amber-50 px-3 py-2 text-center text-[11px] leading-relaxed text-amber-700">{warn}</p>
)}
{/* 链路证据:这些字段就是判断「哪一环通了」的依据,比一句“成功”有用 */}
{orderRef.current && (
<dl className="w-full space-y-1 rounded-lg bg-gray-50 p-3 text-[11px]">
<Row k="订单号" v={<span className="font-mono">{orderRef.current}</span>} />
<Row
k="订单状态"
v={
<span
className={
phase === "paid" ? "font-medium text-emerald-600" : phase === "expired" ? "text-gray-400" : "text-amber-600"
}
>
{order?.status ?? "pending"}
{phase === "waiting" && " · 等待扫码支付"}
</span>
}
/>
<Row k="轮询次数" v={<span className="tabular-nums">{polls}</span>} />
{order?.credits_micro != null && phase === "paid" && (
<Row k="到账积分" v={<span className="tabular-nums text-emerald-600">{(order.credits_micro / 1e6).toLocaleString()}</span>} />
)}
{err && <Row k="最近错误" v={<span className="text-rose-500">{err}</span>} />}
</dl>
)}
{phase === "paid" && (
<p className="text-center text-[11px] leading-relaxed text-emerald-600">
/
</p>
)}
{phase === "waiting" && (
<p className="text-center text-[11px] leading-relaxed text-gray-400">
</p>
)}
</div>
</div>
</div>
);
}
function Row({ k, v }: { k: string; v: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-gray-400">{k}</dt>
<dd className="truncate text-gray-700">{v}</dd>
</div>
);
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { PayProbeDialog } from "./PayProbeDialog";
import { adminPacks, savePack, genRedeemCodes, listRedeemCodes, getWechatPay, saveWechatPay, type CreditPack, type RedeemCodeRow, type WechatPayConfig } from "../api";
// 充值渠道(P5.1,设计见 PAYMENT_DESIGN.md):
@@ -250,6 +251,7 @@ function RedeemBlock() {
// ---- 积分包:钱→积分定价(微信支付上线后用户按包扫码) ----
function PacksBlock() {
const [rows, setRows] = useState<CreditPack[]>([]);
const [probe, setProbe] = useState<CreditPack | null>(null); // 正在联调的积分包
const [name, setName] = useState("");
const [creditsIn, setCreditsIn] = useState("");
const [yuan, setYuan] = useState("");
@@ -326,7 +328,8 @@ function PacksBlock() {
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
@@ -335,22 +338,39 @@ function PacksBlock() {
<td className="py-2 pr-3 text-gray-800">{p.name}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">{credits(p.credits_micro)}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">¥{(p.price_fen / 100).toFixed(2)}</td>
<td className="py-2 text-right">
<td className="py-2 pr-3 text-right">
<button onClick={() => void toggle(p)}
className={`rounded border px-2 py-0.5 text-[11px] ${p.active ? "border-emerald-200 text-emerald-600 hover:bg-emerald-50" : "border-gray-200 text-gray-400 hover:bg-gray-50"}`}>
{p.active ? "在售 · 点击下架" : "已下架 · 点击上架"}
</button>
</td>
<td className="py-2 text-right">
{/* 就地下真单验链路。下架的包下单会被服务端拒,按钮也就没意义 */}
<button onClick={() => setProbe(p)} disabled={!p.active} title={p.active ? "生成付款码,验证支付链路" : "已下架的包不可下单"}
className="rounded border border-violet-200 px-2 py-0.5 text-[11px] text-violet-600 hover:bg-violet-50 disabled:cursor-not-allowed disabled:border-gray-200 disabled:text-gray-300 disabled:hover:bg-transparent">
</button>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={4} className="py-6 text-center text-xs text-gray-400">线</td>
<td colSpan={5} className="py-6 text-center text-xs text-gray-400">线</td>
</tr>
)}
</tbody>
</table>
</div>
{probe && (
<PayProbeDialog
pack={probe}
onClose={() => {
setProbe(null);
void load(); // 联调单也是真单,回来刷一次列表
}}
/>
)}
</div>
);
}
@@ -81,7 +81,11 @@ func (h *Handler) BillingCreateOrder(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": channel + "下单失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"order_id": o.ID, "code_url": intent.CodeURL, "amount_fen": o.AmountFen})
// expires_at 由后端下发:二维码有效期是服务端的 orderTTL 说了算,前端硬编码一份迟早漂移。
c.JSON(http.StatusOK, gin.H{
"order_id": o.ID, "code_url": intent.CodeURL, "amount_fen": o.AmountFen,
"expires_at": o.CreatedAt.Add(orderTTL),
})
}
// BillingOrderStatus: GET /api/v1/billing/orders/:id —— 前端轮询订单态。
@@ -113,25 +117,33 @@ func (h *Handler) BillingOrderStatus(c *gin.Context) {
func (h *Handler) reconcileOrder(ctx context.Context, o *store.PaymentOrder) (*store.PaymentOrder, bool) {
ch := h.pay.Get(o.Channel) // 按订单实际所用渠道查单,不写死微信
if o.Status != store.OrderPending || ch == nil {
forgetQuery(o.ID)
return o, false
}
if r, err := ch.QueryOrder(ctx, o.ID); err == nil {
switch {
case r.Paid && r.AmountFen == o.AmountFen:
if _, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn); err == nil {
// 节流:间隔内跳过真实查单,只走下面本地可判定的 TTL 过期。回调是主路径,
// 少查几次不影响到账,只影响「用户在场时的确认延迟」,最坏多等 5s。
if allowQuery(o.ID) {
if r, err := ch.QueryOrder(ctx, o.ID); err == nil {
switch {
case r.Paid && r.AmountFen == o.AmountFen:
if _, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn); err == nil {
o, _ = h.db.GetOrder(ctx, o.ID)
}
case r.Paid: // 金额对不上:不入账,人工对账(比错账便宜)
return o, true
case r.Closed:
_ = h.db.ExpireOrder(ctx, o.ID)
o, _ = h.db.GetOrder(ctx, o.ID)
}
case r.Paid: // 金额对不上:不入账,人工对账(比错账便宜)
return o, true
case r.Closed:
_ = h.db.ExpireOrder(ctx, o.ID)
o, _ = h.db.GetOrder(ctx, o.ID)
}
}
if o.Status == store.OrderPending && time.Since(o.CreatedAt) > orderTTL {
_ = h.db.ExpireOrder(ctx, o.ID)
o, _ = h.db.GetOrder(ctx, o.ID)
}
if o.Status != store.OrderPending {
forgetQuery(o.ID) // 已落终态,标记没用了
}
return o, false
}
@@ -3,6 +3,7 @@ package handler
import (
"context"
"log"
"sync"
"time"
"github.com/sundynix/sundynix-gateway/internal/payment"
@@ -15,6 +16,43 @@ import (
const reconcileInterval = 1 * time.Minute
// 主动查单节流:前端支付弹窗每 2.5s 轮一次单态,而 reconcileOrder 见 pending 就直连渠道
// 查单——单笔订单在 30min TTL 内能打出约 720 次微信查单调用,微信侧有频控,多用户并发时
// 先被限流的反而是我们自己。回调才是入账主路径,查单只是兜底,给它一个最小间隔即可:
// 间隔内的轮询直接返回本地状态(订单一旦被回调入账,本地状态本来就是最新的)。
// 补偿定时器每分钟才跑一轮,远大于这个间隔,不受影响。
const minQueryInterval = 5 * time.Second
// lastQuery: orderID -> 上次真正打渠道查单的时刻。仅用于限流,进程级即可
// (多实例各自限流,量级仍降两个数量级);订单落终态或超期时清理,见 forgetQuery/pruneQueryMarks。
var lastQuery sync.Map
// allowQuery 判断此刻是否放行一次真实查单,放行则记下时刻。
func allowQuery(orderID string) bool {
now := time.Now()
if v, ok := lastQuery.Load(orderID); ok {
if last, _ := v.(time.Time); now.Sub(last) < minQueryInterval {
return false
}
}
lastQuery.Store(orderID, now)
return true
}
func forgetQuery(orderID string) { lastQuery.Delete(orderID) }
// pruneQueryMarks 清掉超过 TTL 的残留标记 —— 用户扫码前就关掉弹窗的订单不会再被轮询,
// 其标记无人清理,不定期回收会随进程运行时长单调增长。
func pruneQueryMarks() {
cutoff := time.Now().Add(-orderTTL)
lastQuery.Range(func(k, v any) bool {
if t, _ := v.(time.Time); t.Before(cutoff) {
lastQuery.Delete(k)
}
return true
})
}
// StartReconcile 启动掉单补偿定时器(微信渠道未配置时空转,几乎零成本)。随进程生命周期运行,
// ctx 取消即退出。返回给调用方保存以便优雅停机时取消。
func (h *Handler) StartReconcile(ctx context.Context) {
@@ -27,6 +65,7 @@ func (h *Handler) StartReconcile(ctx context.Context) {
return
case <-t.C:
h.reconcilePending(ctx)
pruneQueryMarks()
}
}
}()
@@ -0,0 +1,52 @@
package handler
import (
"testing"
"time"
)
// 查单节流是限制「我们打微信查单接口」频率的唯一闸门:前端支付弹窗 2.5s 轮一次,
// 没有它的话单笔订单在 30min TTL 内能打出数百次渠道调用。这里把行为钉死。
func TestAllowQuery_Throttle(t *testing.T) {
const id = "test-order-throttle"
defer forgetQuery(id)
if !allowQuery(id) {
t.Fatal("首次查单必须放行——否则用户扫码后要白等一个间隔才确认")
}
if allowQuery(id) {
t.Fatal("间隔内的第二次应被拦下")
}
// 间隔过后重新放行
lastQuery.Store(id, time.Now().Add(-minQueryInterval-time.Second))
if !allowQuery(id) {
t.Fatal("超过 minQueryInterval 后应重新放行")
}
}
// 订单落终态后标记要清掉,否则同一订单号(或长期挂着的 pending)会把 map 撑大。
func TestForgetQuery_And_Prune(t *testing.T) {
const id = "test-order-forget"
allowQuery(id)
forgetQuery(id)
if _, ok := lastQuery.Load(id); ok {
t.Fatal("forgetQuery 应删除标记")
}
// 用户扫码前就关掉弹窗的订单不会再被轮询,标记无人清理 —— 靠定时 prune 回收
const stale = "test-order-stale"
lastQuery.Store(stale, time.Now().Add(-orderTTL-time.Minute))
const fresh = "test-order-fresh"
lastQuery.Store(fresh, time.Now())
defer forgetQuery(fresh)
pruneQueryMarks()
if _, ok := lastQuery.Load(stale); ok {
t.Fatal("超过 orderTTL 的陈旧标记应被回收")
}
if _, ok := lastQuery.Load(fresh); !ok {
t.Fatal("仍在有效期内的标记不该被误删")
}
}
+9 -5
View File
@@ -271,8 +271,11 @@ export async function billingPacks(): Promise<{ packs: Pack[]; channels: string[
return { packs: d.packs ?? [], channels: d.channels ?? [] };
}
// createWechatOrder 微信 Native 下单:返回订单号 + code_url(渲染成二维码扫码付)
export async function createWechatOrder(packId: string): Promise<{ order_id: string; code_url: string; amount_fen: number }> {
// createWechatOrder 微信 Native 下单:返回订单号 + code_url(渲染成二维码扫码付)
// + expires_at(二维码有效期,由服务端 orderTTL 决定,前端只负责倒计时展示)。
export async function createWechatOrder(
packId: string,
): Promise<{ order_id: string; code_url: string; amount_fen: number; expires_at: string }> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/billing/orders`, {
method: "POST",
@@ -284,10 +287,11 @@ export async function createWechatOrder(packId: string): Promise<{ order_id: str
}
// orderStatus 轮询订单态(pending 时服务端顺路主动查单,本地也能确认到账)。
export async function orderStatus(orderId: string): Promise<TopupOrder> {
// warn 必须一并返回:服务端在「已付但金额与订单不符」时不入账、挂起人工核对,
// 订单会一直停在 pending —— 丢掉 warn 的话用户付了钱、界面却只会一直转圈等下去。
export async function orderStatus(orderId: string): Promise<{ order: TopupOrder; warn?: string }> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: bearer() }));
const d = await jsonOrThrow<{ order: TopupOrder }>(res, "查询失败");
return d.order;
return jsonOrThrow<{ order: TopupOrder; warn?: string }>(res, "查询失败");
}
// redeemCode 核销兑换码,返回入账后的余额。
+114 -26
View File
@@ -191,11 +191,10 @@ export function Usage() {
pack={paying}
onClose={(paid) => {
setPaying(null);
if (paid) {
toast.push("success", "支付成功,积分已入账");
load();
refresh();
}
if (paid) toast.push("success", "支付成功,积分已入账");
// 无论如何都刷一次:用户可能在关弹窗前一刻付款、状态刚落地还没被轮询看到。
load();
refresh();
}}
/>
)}
@@ -203,51 +202,111 @@ export function Usage() {
);
}
// PayDialog 微信 Native 扫码支付:下单 → code_url 画二维码 → 轮询单态2.5s
// 服务端 pending 时会顺路主动查单,收不到公网回调的环境也能确认到账)。
// PayDialog 微信 Native 扫码支付:下单 → code_url 画二维码 → 轮询单态确认到账。
//
// 轮询这条链上几件必须处理的事:
// - 递归 setTimeout 而非 setInterval:请求慢于间隔时 setInterval 会把请求摞起来,
// 而这个端点每次都可能触发一次渠道查单,摞起来等于放大对微信的调用。
// - 标签页切走时暂停:用户扫完码要切到微信 App 付款,这段时间页面在后台空转没意义;
// 切回来立刻补查一次,回前台的确认几乎无延迟。
// - 连续失败指数退避:断网时别以固定频率猛打,封顶 15s。
// - warn(已付但金额与订单不符)必须显式呈现:这种单服务端**不入账**、订单一直停在
// pending,不提示的话用户会一直等一个永远不会来的结果。
const POLL_BASE_MS = 2500;
const POLL_MAX_MS = 15000;
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) => void }) {
const [qr, setQr] = useState("");
const [err, setErr] = useState("");
const [warn, setWarn] = useState("");
const [state, setState] = useState<"creating" | "waiting" | "paid" | "expired">("creating");
const [left, setLeft] = useState(0); // 二维码剩余有效秒数
const orderRef = useRef("");
const expiresRef = useRef(0);
useEffect(() => {
let alive = true;
let timer: number | null = null;
let failures = 0;
const stop = () => {
if (timer) window.clearTimeout(timer);
timer = null;
};
const schedule = (ms: number) => {
stop();
timer = window.setTimeout(() => void tick(), ms);
};
const tick = async () => {
if (!alive) return;
if (document.hidden) return schedule(POLL_BASE_MS); // 后台不打请求,等切回来补
try {
const { order, warn: w } = await orderStatus(orderRef.current);
if (!alive) return;
failures = 0;
setWarn(w ?? "");
if (order.status === "paid") {
setState("paid");
stop();
window.setTimeout(() => onClose(true), 800);
return;
}
if (order.status === "expired" || order.status === "failed") {
setState("expired");
stop();
return;
}
schedule(POLL_BASE_MS);
} catch {
if (!alive) return;
failures += 1;
schedule(Math.min(POLL_BASE_MS * 2 ** failures, POLL_MAX_MS));
}
};
// 切回前台立刻查一次,不用等下一个 tick
const onVisible = () => {
if (!document.hidden && alive && orderRef.current) schedule(0);
};
document.addEventListener("visibilitychange", onVisible);
(async () => {
try {
const o = await createWechatOrder(pack.id);
if (!alive) return;
orderRef.current = o.order_id;
expiresRef.current = new Date(o.expires_at).getTime();
setLeft(Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000)));
setQr(await QRCode.toDataURL(o.code_url, { width: 240, margin: 1 }));
setState("waiting");
timer = window.setInterval(async () => {
try {
const s = await orderStatus(orderRef.current);
if (!alive) return;
if (s.status === "paid") {
setState("paid");
if (timer) window.clearInterval(timer);
window.setTimeout(() => onClose(true), 800);
} else if (s.status === "expired" || s.status === "failed") {
setState("expired");
if (timer) window.clearInterval(timer);
}
} catch {
/* 单次轮询失败忽略,下个 tick 再试 */
}
}, 2500);
schedule(POLL_BASE_MS);
} catch (e) {
if (alive) setErr((e as Error).message);
}
})();
return () => {
alive = false;
if (timer) window.clearInterval(timer);
stop();
document.removeEventListener("visibilitychange", onVisible);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pack.id]);
// 倒计时:到点本地先置过期,省一次「扫了个死码才知道」。服务端 TTL 仍是权威。
useEffect(() => {
if (state !== "waiting") return;
const t = window.setInterval(() => {
const s = Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000));
setLeft(s);
if (s === 0) setState("expired");
}, 1000);
return () => window.clearInterval(t);
}, [state]);
return (
<Dialog open title={`微信扫码 · ${pack.name}`} onClose={() => onClose(state === "paid")}>
<div className="flex flex-col items-center gap-3 py-2">
@@ -258,15 +317,44 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
) : (
<>
{/* 二维码底色固定纯白:扫码器对暗色主题下的低对比码识别率差 */}
<div className="rounded-lg bg-white p-2">
<div className="relative rounded-lg bg-white p-2">
<img src={qr} alt="微信支付二维码" width={240} height={240} />
{state === "expired" && (
// 盖住失效的码,避免用户扫一个必然失败的二维码
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-slate-900/80 text-xs text-slate-200">
</div>
)}
</div>
<div className="text-center">
<div className="text-lg font-semibold tabular-nums text-slate-100">¥{(pack.price_fen / 100).toFixed(2)}</div>
<div className="mt-1 text-xs text-slate-500">
{state === "paid" ? "✅ 已支付,入账中…" : state === "expired" ? "订单已过期,请关闭后重新下单" : `微信扫一扫支付,到账 ${fmtCredits(pack.credits_micro)} 积分`}
{state === "paid"
? "✅ 已支付,入账中…"
: state === "expired"
? "订单已过期,请关闭后重新下单"
: `微信扫一扫支付,到账 ${fmtCredits(pack.credits_micro)} 积分`}
</div>
{state === "waiting" && (
<div className="mt-1 text-[11px] tabular-nums text-slate-500"> {mmss(left)} </div>
)}
</div>
{/* 已付但金额与订单不符:服务端不入账、挂起人工核对,必须告诉用户别再等 */}
{warn && (
<p className="rounded-md bg-amber-500/10 px-3 py-2 text-center text-[11px] leading-relaxed text-amber-300">
{warn}
<br />
{orderRef.current}
</p>
)}
{/* 掉单补偿定时器在后台兜底,关页面不会丢钱——说清楚,省得用户守着不敢关 */}
{state === "waiting" && !warn && (
<p className="text-center text-[11px] leading-relaxed text-slate-600">
</p>
)}
</>
)}
</div>