fix: improve h5 payment return flow

This commit is contained in:
wushumin
2026-06-04 16:12:59 +08:00
parent 13c21ac67f
commit 46dae160be
13 changed files with 328 additions and 8 deletions

View File

@@ -11,6 +11,8 @@ Use the outer repository as the single source of truth for all project work. Do
Do not manage release artifacts in Git. Files under `releases/` and `releases_dev/`, including zip packages, APKs, and checksum files, are local delivery artifacts and must not be committed or pushed. Put production packages in `releases/` and test packages in `releases_dev/`.
Local development and the test server share the test database connection. Store the real host, username, and password only in ignored `.env` files or server environment variables. The shared test database name is `test_anxinyan`; never commit real database passwords to docs, skills, examples, or Git-tracked templates.
## Branch Model
- `master`: stable, release-ready mainline. Do not do daily development directly here.
@@ -52,15 +54,42 @@ Before committing, run:
```bash
git status -sb
git diff --check
npx gitnexus detect-changes --scope all
npx gitnexus detect-changes --scope all --repo anxinyan
```
Before changing code symbols, follow the repository `AGENTS.md` GitNexus rule: run upstream impact analysis for the target symbol and report the blast radius. Before committing any change, run GitNexus detect changes.
Use GitNexus with the branch workflow:
- Before changing unfamiliar code, use GitNexus query/context to locate the relevant flow.
- Before editing any function, class, or method, run upstream impact analysis for that symbol.
- Before committing, run detect-changes against the current diff; this remains a required check even when hooks are enabled.
- After commits, merges, branch checkouts, and rewrites, local hooks refresh the index with `npx gitnexus analyze --index-only --name anxinyan .`.
Enable the versioned lightweight hooks once per clone:
```bash
git config core.hooksPath scripts/git-hooks
```
The hook refresh is best-effort and non-blocking. Disable it temporarily with `GITNEXUS_AUTO_REFRESH=0` if needed. Manual refresh is available with:
```bash
./scripts/gitnexus-refresh.sh
```
For release packaging or deployment preparation, also follow the release checklist and packaging skill. At minimum, run the relevant backend smoke/audit scripts and frontend checks for the surfaces touched.
Production packages should stay under the ignored local `releases/` directory and use `https://api.anxinjianyan.com`. Test packages should stay under the ignored local `releases_dev/` directory and use `https://test.api.anxinjianyan.com`. Hand off both kinds of artifacts separately from Git branches.
For backend local/test validation, confirm `server-api/.env` is ignored before using real database credentials:
```bash
git check-ignore -v server-api/.env
```
Before committing environment-related documentation, scan tracked docs and examples to ensure no real `DB_PASSWORD` value is present.
## Documentation Source
The human-readable branch workflow is documented in:

View File

@@ -4,6 +4,8 @@
`releases/``releases_dev/` 下的 zip、apk、校验文件等发布产物只作为本地交付物管理不纳入 Git 分支管理范围,也不要提交或推送到远程仓库。正式包放 `releases/`,测试包放 `releases_dev/`
本地开发环境和测试服共用测试数据库连接,测试库名为 `test_anxinyan`。真实的数据库 host、用户名和密码只允许放在被忽略的 `server-api/.env` 或服务器环境变量中,不写入 README、规范文档、Skill、`.env.example` 或其他可提交模板。
## 长期分支
| 分支 | 用途 | 维护规则 |
@@ -44,13 +46,41 @@ git switch -c feature/admin-report-export
```bash
git status -sb
git diff --check
npx gitnexus detect-changes --scope all
npx gitnexus detect-changes --scope all --repo anxinyan
```
如果修改了 PHP 后端文件,补充运行相关 PHP 语法检查或项目脚本;如果修改了前端,按影响端运行对应的类型检查或构建。正式包发版前按上线检查清单执行 `server-api/tools/release_audit.php``server-api/tools/smoke_check.php` 和相关客户端构建;测试包构建前确认各端测试环境配置指向 `https://test.api.anxinjianyan.com`
正式包生成后保留在本地 `releases/` 目录,测试包生成后保留在本地 `releases_dev/` 目录并按需另行交付Git 提交中不包含这些产物。
涉及本地或测试服数据库配置时,提交前确认 `server-api/.env` 仍被 Git 忽略,并检查 README、docs、`.claude``.env.example` 等可提交文件中没有真实数据库密码:
```bash
git check-ignore -v server-api/.env
```
## GitNexus 索引协同
Git 分支负责协作路径GitNexus 负责改动影响面。推荐节奏:
1. 开发前在 `develop` 上创建 `feature/<scope>-<name>`
2. 不熟悉代码时先用 GitNexus query/context 找入口和流程。
3. 修改函数、类或方法前先做 upstream impact analysis。
4. 提交前运行 `npx gitnexus detect-changes --scope all --repo anxinyan`
5. 提交、合并、切换分支或 rebase 后,由本地 hook 异步刷新 GitNexus 索引。
团队成员首次 clone 后执行一次:
```bash
git config core.hooksPath scripts/git-hooks
```
自动刷新只执行 `npx gitnexus analyze --index-only --name anxinyan .`,不会更新 `AGENTS.md``CLAUDE.md` 或 GitNexus skills。它不替代提交前的 detect-changes 检查。需要临时关闭时,在 Git 命令前加 `GITNEXUS_AUTO_REFRESH=0`;需要手动刷新时运行:
```bash
./scripts/gitnexus-refresh.sh
```
## 发版流程
1. 从最新 `develop` 创建 `release/<version-or-date>`

18
scripts/git-hooks/post-checkout Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
# Third argument is 1 for branch checkout and 0 for file checkout.
[ "${3:-0}" = "1" ] || exit 0
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
case "$git_dir" in
/*) ;;
*) git_dir="$repo_root/$git_dir" ;;
esac
(
cd "$repo_root" || exit 0
./scripts/gitnexus-refresh.sh
) >>"$git_dir/gitnexus-refresh.log" 2>&1 &
exit 0

15
scripts/git-hooks/post-commit Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
case "$git_dir" in
/*) ;;
*) git_dir="$repo_root/$git_dir" ;;
esac
(
cd "$repo_root" || exit 0
./scripts/gitnexus-refresh.sh
) >>"$git_dir/gitnexus-refresh.log" 2>&1 &
exit 0

15
scripts/git-hooks/post-merge Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
case "$git_dir" in
/*) ;;
*) git_dir="$repo_root/$git_dir" ;;
esac
(
cd "$repo_root" || exit 0
./scripts/gitnexus-refresh.sh
) >>"$git_dir/gitnexus-refresh.log" 2>&1 &
exit 0

15
scripts/git-hooks/post-rewrite Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
case "$git_dir" in
/*) ;;
*) git_dir="$repo_root/$git_dir" ;;
esac
(
cd "$repo_root" || exit 0
./scripts/gitnexus-refresh.sh
) >>"$git_dir/gitnexus-refresh.log" 2>&1 &
exit 0

54
scripts/gitnexus-refresh.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/bin/sh
set -eu
if [ "${GITNEXUS_AUTO_REFRESH:-1}" = "0" ]; then
echo "GitNexus auto refresh disabled by GITNEXUS_AUTO_REFRESH=0"
exit 0
fi
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
echo "Not inside a git repository"
exit 1
}
cd "$repo_root"
git_dir=$(git rev-parse --git-dir)
case "$git_dir" in
/*) ;;
*) git_dir="$repo_root/$git_dir" ;;
esac
head_commit=$(git rev-parse HEAD 2>/dev/null || true)
if [ -z "$head_commit" ]; then
echo "No HEAD commit found; skip GitNexus refresh"
exit 0
fi
stamp_file="$git_dir/gitnexus-indexed-head"
lock_dir="$git_dir/gitnexus-refresh.lock"
if [ -f "$stamp_file" ] && [ "$(cat "$stamp_file" 2>/dev/null || true)" = "$head_commit" ]; then
echo "GitNexus index already refreshed for $head_commit"
exit 0
fi
if ! mkdir "$lock_dir" 2>/dev/null; then
echo "GitNexus refresh already running; skip"
exit 0
fi
cleanup() {
rmdir "$lock_dir" 2>/dev/null || true
}
trap cleanup EXIT HUP INT TERM
echo "GitNexus refresh started for $head_commit"
if npx gitnexus analyze --index-only --name anxinyan .; then
printf '%s\n' "$head_commit" > "$stamp_file"
echo "GitNexus refresh completed for $head_commit"
else
status=$?
echo "GitNexus refresh failed with exit code $status"
exit "$status"
fi

View File

@@ -8,9 +8,9 @@ PUBLIC_FILE_BASE_URL=
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=anxinyan
DB_USERNAME=root
DB_PASSWORD=change_me
DB_DATABASE=test_anxinyan
DB_USERNAME=
DB_PASSWORD=
DB_CHARSET=utf8mb4
DB_PREFIX=

View File

@@ -88,7 +88,11 @@ class ShouqianbaConfigService
return '';
}
return $baseUrl . '/#/pages/order/detail?id=' . $orderId;
$fallbackQuery = http_build_query([
'sqb_return_order_id' => $orderId,
], '', '&', PHP_QUERY_RFC3986);
return $baseUrl . '/?' . $fallbackQuery . '#/pages/order/detail?id=' . $orderId;
}
public function miniProgramCallbackPath(int $orderId): string

View File

@@ -54,6 +54,11 @@ class ShouqianbaPaymentService
$latest = $this->latestPayment($orderId);
if ($latest && in_array((string)$latest['status'], ['pending', 'created'], true) && (string)$latest['order_token'] !== '') {
if ($this->shouldRefreshH5ReturnUrl($latest, $order)) {
$replacement = $this->createPayment($order);
$this->markPaymentReplaced($latest);
return $replacement;
}
return $this->buildPaymentLaunchPayload($latest, $order);
}
@@ -281,6 +286,44 @@ class ShouqianbaPaymentService
return $payload;
}
private function shouldRefreshH5ReturnUrl(array $payment, array $order): bool
{
if ((string)$order['source_channel'] !== 'h5') {
return false;
}
$expectedUrl = $this->configService->h5OrderDetailUrl((int)$order['id']);
if ($expectedUrl === '') {
return false;
}
$requestJson = json_decode((string)($payment['request_json'] ?? ''), true);
if (!is_array($requestJson)) {
return true;
}
$body = $requestJson['body'] ?? ($requestJson['request']['body'] ?? null);
if (!is_array($body)) {
return true;
}
return (string)($body['return_url'] ?? '') !== $expectedUrl
|| (string)($body['back_url'] ?? '') !== $expectedUrl;
}
private function markPaymentReplaced(array $payment): void
{
$now = date('Y-m-d H:i:s');
Db::name('shouqianba_payments')
->where('id', (int)$payment['id'])
->whereIn('status', ['pending', 'created'])
->update([
'status' => 'replaced',
'cancelled_at' => $now,
'updated_at' => $now,
]);
}
private function queryRemotePayment(array $payment): array
{
$config = $this->configService->assertReady(true);

View File

@@ -320,6 +320,32 @@ try {
assertTrue(($launch['status'] ?? '') === 'pending', 'purchase should create pending payment');
assertTrue(($launch['cashier_url'] ?? '') !== '', 'purchase cashier_url missing');
$payment = latestPayment($notifyOrderId);
$requestJson = json_decode((string)$payment['request_json'], true);
$purchaseBody = is_array($requestJson) ? ($requestJson['body'] ?? []) : [];
assertTrue(
($purchaseBody['return_url'] ?? '') === 'https://m.example.com/?sqb_return_order_id=' . $notifyOrderId . '#/pages/order/detail?id=' . $notifyOrderId,
'purchase return_url should include H5 order detail fallback'
);
assertTrue(($purchaseBody['back_url'] ?? '') === ($purchaseBody['return_url'] ?? ''), 'purchase back_url should match return_url');
$staleReturnOrderId = createMockOrder($userId, 'STALERETURN');
$service->createOrReusePayment($staleReturnOrderId);
$stalePayment = latestPayment($staleReturnOrderId);
$staleRequestJson = json_decode((string)$stalePayment['request_json'], true);
if (is_array($staleRequestJson)) {
$staleRequestJson['body']['return_url'] = 'https://m.example.com/#/pages/order/detail?id=' . $staleReturnOrderId;
$staleRequestJson['body']['back_url'] = $staleRequestJson['body']['return_url'];
Db::name('shouqianba_payments')->where('id', (int)$stalePayment['id'])->update([
'request_json' => json_encode($staleRequestJson, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
}
$replacement = $service->createOrReusePayment($staleReturnOrderId);
$stalePaymentAfterReplace = Db::name('shouqianba_payments')->where('id', (int)$stalePayment['id'])->find();
$latestReplacement = latestPayment($staleReturnOrderId);
assertTrue((string)($stalePaymentAfterReplace['status'] ?? '') === 'replaced', 'stale H5 payment should be marked replaced');
assertTrue((int)$latestReplacement['id'] !== (int)$stalePayment['id'], 'stale H5 payment should be replaced with a new payment row');
assertTrue(($replacement['check_sn'] ?? '') === (string)$latestReplacement['check_sn'], 'replacement launch payload should use the new payment row');
$notifyPayload = [
'check_sn' => $payment['check_sn'],
'order_status' => '4',

View File

@@ -10,6 +10,7 @@ const orders = ref<OrderListItem[]>([]);
const privacyMode = ref(getPrivacyMode());
const orderHeroBackground = ref("");
const defaultOrderHeroBackground = "/static/order/order-reference.jpg";
let orderRefreshTicket = 0;
const orderHeroStyle = computed(() => ({
backgroundImage: `url("${orderHeroBackground.value || defaultOrderHeroBackground}")`,
@@ -67,17 +68,59 @@ async function fetchPageVisuals() {
}
}
function shouldSyncPaymentStatus(item: OrderListItem) {
return item.order_status === "pending_payment" && item.payment_status !== "paid";
}
async function syncPendingPaymentOrders(list: OrderListItem[]) {
const pendingOrders = list.filter(shouldSyncPaymentStatus);
if (!pendingOrders.length) {
return false;
}
const results = await Promise.all(
pendingOrders.map(async (item) => {
try {
const data = await appApi.getOrderPaymentStatus(item.order_id);
return data.order_status !== item.order_status
|| data.payment_status !== item.payment_status
|| data.display_status !== item.display_status;
} catch (error) {
console.warn("order payment status sync skipped", error);
return false;
}
}),
);
return results.some(Boolean);
}
async function refreshOrdersWithPaymentSync() {
const ticket = ++orderRefreshTicket;
const data = await appApi.getOrders();
if (ticket !== orderRefreshTicket) return;
orders.value = data.list;
const needsRefresh = await syncPendingPaymentOrders(data.list);
if (!needsRefresh || ticket !== orderRefreshTicket) return;
const refreshed = await appApi.getOrders();
if (ticket === orderRefreshTicket) {
orders.value = refreshed.list;
}
}
onShow(async () => {
privacyMode.value = getPrivacyMode();
void fetchPageVisuals();
if (!isLoggedIn()) {
orderRefreshTicket += 1;
orders.value = [];
redirectToLogin("/pages/order/index");
return;
}
try {
const data = await appApi.getOrders();
orders.value = data.list;
await refreshOrdersWithPaymentSync();
} catch (error) {
orders.value = [];
showErrorToast(error, "订单加载失败");

View File

@@ -192,7 +192,35 @@ export function redirectToLogin(targetUrl?: string) {
});
}
function consumeShouqianbaH5Return() {
// #ifdef H5
const url = new URL(window.location.href);
const rawOrderId = url.searchParams.get("sqb_return_order_id") || "";
const orderId = Number(rawOrderId);
if (!Number.isInteger(orderId) || orderId <= 0) {
return false;
}
url.searchParams.delete("sqb_return_order_id");
window.history.replaceState({}, document.title, url.toString());
const targetUrl = `/pages/order/detail?id=${orderId}`;
if (!isLoggedIn()) {
redirectToLogin(targetUrl);
return true;
}
uni.reLaunch({ url: targetUrl });
return true;
// #endif
return false;
}
export function ensureAuthenticatedPageAccess() {
if (consumeShouqianbaH5Return()) {
return;
}
const currentUrl = getCurrentPageUrl();
if (!currentUrl || !isAuthRequiredPage(currentUrl) || isLoggedIn()) {
return;